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..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 @@ -26,25 +26,23 @@ 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 { isTeamOwner } from "cyberstorm/utils/permissions"; 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,30 +54,28 @@ export default function Settings() { return ( Loading...}> - - {(resolvedTeamName) => ( + + {(resolvedPermissions) => (

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 ? ( + + ) : ( + + )}
@@ -87,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 {resolvedTeamName}.

-

- 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) ? ( + + ) : ( + + )}
@@ -115,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; @@ -147,7 +162,7 @@ function LeaveTeamForm(props: { - {teamName} + {teamName}. @@ -269,7 +284,7 @@ function DisbandTeamForm(props: { - {teamName} + {teamName}.
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"; 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)),