From 4b2746bd04df885b93125498483a43d93ff67a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 7 Nov 2025 14:57:58 +0200 Subject: [PATCH 1/5] Prepare team profile component for moving to use Suspense As part of the upcoming change, clientLoader will start returning a Promise rather than the resolved value. For Suspense to work, the body of the component function can't depend on the resolved values, so move such parts into subcomponents. --- .../teams/team/tabs/Profile/Profile.tsx | 66 ++++++++++--------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx index ef26cd41e..09f55c4b0 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx @@ -4,6 +4,7 @@ import { useLoaderData, useOutletContext, useRevalidator } from "react-router"; import { NewButton, NewTextInput, useToast } from "@thunderstore/cyberstorm"; import { teamDetailsEdit, + type TeamDetails, type TeamDetailsEditRequestData, } from "@thunderstore/thunderstore-api"; @@ -27,10 +28,18 @@ export function HydrateFallback() { export default function Profile() { const { team } = useLoaderData(); - const outletContext = useOutletContext() as OutletContextShape; - const revalidator = useRevalidator(); + return ( +
+ +
+ ); +} +function ProfileForm(props: { team: TeamDetails }) { + const { team } = props; + const outletContext = useOutletContext() as OutletContextShape; + const revalidator = useRevalidator(); const toast = useToast(); function formFieldUpdateAction( @@ -93,37 +102,34 @@ export default function Profile() { }); return ( -
-
-
-

Donation Link

-
-
-
-
- URL - - updateFormFieldState({ - field: "donation_link", - value: e.target.value, - }) - } - rootClasses="team-profile__input" - /> -
+
+
+

Donation Link

+
+
+
+
+ URL + + updateFormFieldState({ + field: "donation_link", + value: e.target.value, + }) + } + rootClasses="team-profile__input" + />
- - Save changes -
+ + Save changes +
); } + +ProfileForm.displayName = "ProfileForm"; From da1b0fc60c0143d40584640efd434b1f32beec08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 7 Nov 2025 15:00:52 +0200 Subject: [PATCH 2/5] Prepare team members component for moving to use Suspense As part of the upcoming change, clientLoader will start returning a Promise rather than the resolved value. For Suspense to work, the body of the component function can't depend on the resolved values, so move such parts into subcomponents. Also adds link to the "team settings settings tab" for users own row on the membership table to make it clearer how they can remove themselves from a team. --- .../teams/team/tabs/Members/MemberAddForm.tsx | 162 ++++++++ .../teams/team/tabs/Members/Members.css | 4 + .../teams/team/tabs/Members/Members.tsx | 355 +----------------- .../teams/team/tabs/Members/MembersTable.tsx | 241 ++++++++++++ 4 files changed, 415 insertions(+), 347 deletions(-) create mode 100644 apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MemberAddForm.tsx create mode 100644 apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MembersTable.tsx diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MemberAddForm.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MemberAddForm.tsx new file mode 100644 index 000000000..aa4cd3383 --- /dev/null +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MemberAddForm.tsx @@ -0,0 +1,162 @@ +import { faPlus } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useState, useReducer } from "react"; + +import { + useToast, + Modal, + NewButton, + NewIcon, + NewTextInput, + NewSelect, + type SelectOption, +} from "@thunderstore/cyberstorm"; +import { + teamAddMember, + type RequestConfig, + type TeamAddMemberRequestData, +} from "@thunderstore/thunderstore-api"; + +import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; + +const roleOptions: SelectOption<"owner" | "member">[] = [ + { value: "member", label: "Member" }, + { value: "owner", label: "Owner" }, +]; + +export function MemberAddForm(props: { + teamName: string; + updateTrigger: () => Promise; + config: () => RequestConfig; +}) { + const toast = useToast(); + const [open, setOpen] = useState(false); + + function formFieldUpdateAction( + state: TeamAddMemberRequestData, + action: { + field: keyof TeamAddMemberRequestData; + value: TeamAddMemberRequestData[keyof TeamAddMemberRequestData]; + } + ) { + return { + ...state, + [action.field]: action.value, + }; + } + + const [formInputs, updateFormFieldState] = useReducer(formFieldUpdateAction, { + username: "", + role: "member", + }); + + type SubmitorOutput = Awaited>; + + async function submitor(data: typeof formInputs): Promise { + return await teamAddMember({ + config: props.config, + params: { team_name: props.teamName }, + queryParams: {}, + data: { username: data.username, role: data.role }, + }); + } + + type InputErrors = { + [key in keyof typeof formInputs]?: string | string[]; + }; + + const strongForm = useStrongForm< + typeof formInputs, + TeamAddMemberRequestData, + Error, + SubmitorOutput, + Error, + InputErrors + >({ + inputs: formInputs, + submitor, + onSubmitSuccess: () => { + props.updateTrigger(); + toast.addToast({ + csVariant: "success", + children: `Team member added`, + duration: 4000, + }); + setOpen(false); + }, + onSubmitError: (error) => { + toast.addToast({ + csVariant: "danger", + children: `Error occurred: ${error.message || "Unknown error"}`, + duration: 8000, + }); + }, + }); + + return ( + + Add Member + + + + + } + > + +
+ Enter the username of the user you wish to add to the team{" "} + {props.teamName}. +
+
+
+ + { + updateFormFieldState({ + field: "username", + value: e.target.value, + }); + }} + rootClasses="add-member-form__username-input" + id="username" + /> +
+
+ + { + updateFormFieldState({ field: "role", value: value }); + }} + id="role" + /> +
+
+
+ + + Add member + + +
+ ); +} + +MemberAddForm.displayName = "MemberAddForm"; diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.css b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.css index 19a50be02..c1547d56a 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.css +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.css @@ -92,4 +92,8 @@ font-weight: var(--font-weight-bold); line-height: var(--line-height-bold); } + + .members_table__span { + font-size: var(--font-size-body-md); + } } diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx index 32f52ad6c..87e0a1bb0 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx @@ -1,33 +1,9 @@ -import { faPlus, faTrashCan } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useReducer, useState } from "react"; import { useLoaderData, useOutletContext, useRevalidator } from "react-router"; -import { - Modal, - NewAvatar, - NewButton, - NewIcon, - NewLink, - NewSelect, - NewTable, - NewTextInput, - type SelectOption, - useToast, -} from "@thunderstore/cyberstorm"; -import { TableSort } from "@thunderstore/cyberstorm/src/newComponents/Table/Table"; -import { - type RequestConfig, - teamAddMember, - type TeamAddMemberRequestData, - teamEditMember, - teamRemoveMember, -} from "@thunderstore/thunderstore-api"; -import { ApiAction } from "@thunderstore/ts-api-react-actions"; - import { type OutletContextShape } from "app/root"; import { makeTeamSettingsTabLoader } from "cyberstorm/utils/dapperClientLoaders"; -import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; +import { MemberAddForm } from "./MemberAddForm"; +import { MembersTable } from "./MembersTable"; import "./Members.css"; export const clientLoader = makeTeamSettingsTabLoader( @@ -40,351 +16,36 @@ export function HydrateFallback() { return
Loading...
; } -const teamMemberColumns = [ - { value: "User", disableSort: false }, - { value: "Role", disableSort: false }, - { value: "Actions", disableSort: true }, -]; - -const roleOptions: SelectOption<"owner" | "member">[] = [ - { value: "member", label: "Member" }, - { value: "owner", label: "Owner" }, -]; - export default function Members() { const { teamName, members } = useLoaderData(); const outletContext = useOutletContext() as OutletContextShape; - const revalidator = useRevalidator(); async function teamMemberRevalidate() { revalidator.revalidate(); } - const currentUser = outletContext.currentUser; - const currentUserTeam = currentUser?.teams_full?.find( - (team) => team.name === teamName - ); - const isOwner = currentUserTeam?.role === "owner"; - - const toast = useToast(); - - const changeMemberRoleAction = ApiAction({ - endpoint: teamEditMember, - onSubmitSuccess: () => { - toast.addToast({ - csVariant: "success", - children: `Member role updated`, - duration: 4000, - }); - teamMemberRevalidate(); - }, - onSubmitError: (error) => { - toast.addToast({ - csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, - duration: 8000, - }); - }, - }); - - function changeMemberRole(username: string, value: "owner" | "member") { - changeMemberRoleAction({ - params: { teamIdentifier: teamName, username }, - data: { role: value }, - queryParams: {}, - config: outletContext.requestConfig, - }); - } - - const tableData = members.map((member, index) => { - return [ - { - value: ( - - - {member.username} - - ), - sortValue: member.username, - }, - { - value: ( -
- - changeMemberRole(member.username, val) - } - disabled={!isOwner || currentUser?.username === member.username} - /> -
- ), - sortValue: member.role, - }, - { - value: - !isOwner || currentUser?.username === member.username ? null : ( - - ), - sortValue: 0, - }, - ]; - }); - return (

Teams

Manage your teams

-
-
); } - -function AddTeamMemberForm(props: { - teamName: string; - updateTrigger: () => Promise; - config: () => RequestConfig; -}) { - const toast = useToast(); - const [open, setOpen] = useState(false); - - function formFieldUpdateAction( - state: TeamAddMemberRequestData, - action: { - field: keyof TeamAddMemberRequestData; - value: TeamAddMemberRequestData[keyof TeamAddMemberRequestData]; - } - ) { - return { - ...state, - [action.field]: action.value, - }; - } - - const [formInputs, updateFormFieldState] = useReducer(formFieldUpdateAction, { - username: "", - role: "member", - }); - - type SubmitorOutput = Awaited>; - - async function submitor(data: typeof formInputs): Promise { - return await teamAddMember({ - config: props.config, - params: { team_name: props.teamName }, - queryParams: {}, - data: { username: data.username, role: data.role }, - }); - } - - type InputErrors = { - [key in keyof typeof formInputs]?: string | string[]; - }; - - const strongForm = useStrongForm< - typeof formInputs, - TeamAddMemberRequestData, - Error, - SubmitorOutput, - Error, - InputErrors - >({ - inputs: formInputs, - submitor, - onSubmitSuccess: () => { - props.updateTrigger(); - toast.addToast({ - csVariant: "success", - children: `Team member added`, - duration: 4000, - }); - setOpen(false); - }, - onSubmitError: (error) => { - toast.addToast({ - csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, - duration: 8000, - }); - }, - }); - - return ( - - Add Member - - - - - } - > - -
- Enter the username of the user you wish to add to the team{" "} - {props.teamName}. -
-
-
- - { - updateFormFieldState({ - field: "username", - value: e.target.value, - }); - }} - rootClasses="add-member-form__username-input" - id="username" - /> -
-
- - { - updateFormFieldState({ field: "role", value: value }); - }} - id="role" - /> -
-
-
- - - Add member - - -
- ); -} - -AddTeamMemberForm.displayName = "AddTeamMemberForm"; - -function RemoveTeamMemberForm(props: { - indexKey?: string; - userName: string; - teamName: string; - updateTrigger: () => Promise; - config: () => RequestConfig; -}) { - const toast = useToast(); - const [open, setOpen] = useState(false); - - const kickMemberAction = ApiAction({ - endpoint: teamRemoveMember, - onSubmitSuccess: () => { - props.updateTrigger(); - toast.addToast({ - csVariant: "success", - children: `Team member removed`, - duration: 4000, - }); - }, - onSubmitError: (error) => { - toast.addToast({ - csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, - duration: 8000, - }); - }, - }); - - return ( - - - - - Kick - - } - > - -
- You are about to kick member{" "} - - {props.userName} - - . -
-
- - - Cancel - - - kickMemberAction({ - config: props.config, - params: { team_name: props.teamName, username: props.userName }, - queryParams: {}, - data: {}, - }).then(() => { - setOpen(false); - }) - } - > - Kick member - - -
- ); -} - -RemoveTeamMemberForm.displayName = "RemoveTeamMemberForm"; diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MembersTable.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MembersTable.tsx new file mode 100644 index 000000000..bee87f952 --- /dev/null +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/MembersTable.tsx @@ -0,0 +1,241 @@ +import { faTrashCan } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useState } from "react"; +import { useOutletContext } from "react-router"; + +import { + Modal, + NewAvatar, + NewButton, + NewIcon, + NewLink, + NewSelect, + NewTable, + useToast, + type SelectOption, +} from "@thunderstore/cyberstorm"; +import { TableSort } from "@thunderstore/cyberstorm/src/newComponents/Table/Table"; +import { + teamEditMember, + teamRemoveMember, + type RequestConfig, + type TeamMember, +} from "@thunderstore/thunderstore-api"; +import { ApiAction } from "@thunderstore/ts-api-react-actions"; + +import { type OutletContextShape } from "app/root"; + +const teamMemberColumns = [ + { value: "User", disableSort: false }, + { value: "Role", disableSort: false }, + { value: "Actions", disableSort: true }, +]; + +const roleOptions: SelectOption<"owner" | "member">[] = [ + { value: "member", label: "Member" }, + { value: "owner", label: "Owner" }, +]; + +export function MembersTable(props: { + config: () => RequestConfig; + members: TeamMember[]; + teamName: string; + updateTrigger: () => Promise; +}) { + const { config, members, teamName, updateTrigger } = props; + const outletContext = useOutletContext() as OutletContextShape; + const toast = useToast(); + + const currentUser = outletContext.currentUser; + const membership = currentUser?.teams_full.find((t) => t.name === teamName); + const isOwner = membership?.role === "owner"; + + const canManageMember = (username: string) => + isOwner && currentUser?.username !== username; + + const changeMemberRoleAction = ApiAction({ + endpoint: teamEditMember, + onSubmitSuccess: () => { + toast.addToast({ + csVariant: "success", + children: `Member role updated`, + duration: 4000, + }); + updateTrigger(); + }, + onSubmitError: (error) => { + toast.addToast({ + csVariant: "danger", + children: `Error occurred: ${error.message || "Unknown error"}`, + duration: 8000, + }); + }, + }); + + function changeMemberRole(username: string, value: "owner" | "member") { + changeMemberRoleAction({ + params: { teamIdentifier: teamName, username }, + data: { role: value }, + queryParams: {}, + config, + }); + } + + const tableData = members.map((member, index) => { + return [ + { + value: ( + + + {member.username} + + ), + sortValue: member.username, + }, + { + value: ( +
+ + changeMemberRole(member.username, val) + } + disabled={!canManageMember(member.username)} + /> +
+ ), + sortValue: member.role, + }, + { + value: canManageMember(member.username) ? ( + + ) : currentUser?.username === member.username ? ( + + Use the{" "} + + Settings tab + {" "} + to leave the team. + + ) : null, + sortValue: 0, + }, + ]; + }); + + return ( + + ); +} + +MembersTable.displayName = "MembersTable"; + +function RemoveTeamMemberForm(props: { + indexKey?: string; + userName: string; + teamName: string; + updateTrigger: () => Promise; + config: () => RequestConfig; +}) { + const toast = useToast(); + const [open, setOpen] = useState(false); + + const kickMemberAction = ApiAction({ + endpoint: teamRemoveMember, + onSubmitSuccess: () => { + props.updateTrigger(); + toast.addToast({ + csVariant: "success", + children: `Team member removed`, + duration: 4000, + }); + }, + onSubmitError: (error) => { + toast.addToast({ + csVariant: "danger", + children: `Error occurred: ${error.message || "Unknown error"}`, + duration: 8000, + }); + }, + }); + + return ( + + + + + Kick + + } + > + +
+ You are about to kick member{" "} + + {props.userName} + + . +
+
+ + + Cancel + + + kickMemberAction({ + config: props.config, + params: { team_name: props.teamName, username: props.userName }, + queryParams: {}, + data: {}, + }).then(() => { + setOpen(false); + }) + } + > + Kick member + + +
+ ); +} + +RemoveTeamMemberForm.displayName = "RemoveTeamMemberForm"; From 42b39aad78b42ec6e6c243949e80119dd46fb1a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 7 Nov 2025 15:43:24 +0200 Subject: [PATCH 3/5] Prepare team service accounts component for moving to use Suspense As part of the upcoming change, clientLoader will start returning a Promise rather than the resolved value. For Suspense to work, the body of the component function can't depend on the resolved values, so move such parts into subcomponents. --- .../tabs/ServiceAccounts/ServiceAccounts.tsx | 89 +++++-------------- .../ServiceAccounts/ServiceAccountsTable.tsx | 69 ++++++++++++++ 2 files changed, 90 insertions(+), 68 deletions(-) create mode 100644 apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccountsTable.tsx diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx index 81c822ab4..84bb49ca4 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx @@ -7,15 +7,11 @@ import { NewAlert, NewButton, Modal, - NewTable, NewIcon, NewTextInput, - Heading, CodeBox, } from "@thunderstore/cyberstorm"; -import { TableSort } from "@thunderstore/cyberstorm/src/newComponents/Table/Table"; import { - type RequestConfig, teamAddServiceAccount, type TeamServiceAccountAddRequestData, } from "@thunderstore/thunderstore-api"; @@ -23,7 +19,7 @@ import { import { type OutletContextShape } from "app/root"; import { makeTeamSettingsTabLoader } from "cyberstorm/utils/dapperClientLoaders"; import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; -import { ServiceAccountRemoveModal } from "./ServiceAccountRemoveModal"; +import { ServiceAccountsTable } from "./ServiceAccountsTable"; import "./ServiceAccounts.css"; export const clientLoader = makeTeamSettingsTabLoader( @@ -36,81 +32,30 @@ export function HydrateFallback() { return
Loading...
; } -const serviceAccountColumns = [ - { value: "Nickname", disableSort: false }, - { value: "Last Used", disableSort: false }, - { value: "Actions", disableSort: true }, -]; - export default function ServiceAccounts() { const { teamName, serviceAccounts } = useLoaderData(); - const outletContext = useOutletContext() as OutletContextShape; - const revalidator = useRevalidator(); - const currentUserTeam = outletContext.currentUser?.teams_full?.find( - (team) => team.name === teamName - ); - const isOwner = currentUserTeam?.role === "owner"; - async function serviceAccountRevalidate() { revalidator.revalidate(); } - const tableData = serviceAccounts.map((serviceAccount) => { - return [ - { - value: ( -

- {serviceAccount.name} -

- ), - sortValue: serviceAccount.name, - }, - { - value: ( -

- {serviceAccount.last_used ?? "Never"} -

- ), - sortValue: serviceAccount.last_used ?? "0", - }, - { - value: ( - - ), - sortValue: 0, - }, - ]; - }); - return (

Service accounts

Your loyal servants

- {isOwner && ( - - )} +
- Service Accounts} - headers={serviceAccountColumns} - rows={tableData} - sortByHeader={1} - sortDirection={TableSort.ASC} +
@@ -120,9 +65,9 @@ export default function ServiceAccounts() { function AddServiceAccountForm(props: { teamName: string; - config: () => RequestConfig; - serviceAccountRevalidate?: () => void; + serviceAccountRevalidate: () => Promise; }) { + const outletContext = useOutletContext() as OutletContextShape; const [open, setOpen] = useState(false); const [serviceAccountAdded, setServiceAccountAdded] = useState(false); const [addedServiceAccountToken, setAddedServiceAccountToken] = useState(""); @@ -130,6 +75,14 @@ function AddServiceAccountForm(props: { useState(""); const [error, setError] = useState(null); + const currentUserTeam = outletContext.currentUser?.teams_full?.find( + (team) => team.name === props.teamName + ); + + if (currentUserTeam?.role !== "owner") { + return null; + } + function onSuccess( result: Awaited> ) { @@ -161,7 +114,7 @@ function AddServiceAccountForm(props: { async function submitor(data: typeof formInputs): Promise { return await teamAddServiceAccount({ - config: props.config, + config: outletContext.requestConfig, params: { team_name: props.teamName }, queryParams: {}, data: { nickname: data.nickname.trim() }, @@ -188,7 +141,7 @@ function AddServiceAccountForm(props: { // Refresh the service accounts list to show the newly created account // TODO: When API returns identifier in response, we can append the new // service account to the list instead of refreshing from backend - props.serviceAccountRevalidate?.(); + props.serviceAccountRevalidate(); }, onSubmitError: (error) => { const message = `Error occurred: ${error.message || "Unknown error"}`; diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccountsTable.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccountsTable.tsx new file mode 100644 index 000000000..61a9481df --- /dev/null +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccountsTable.tsx @@ -0,0 +1,69 @@ +import { useOutletContext } from "react-router"; + +import { NewTable, Heading } from "@thunderstore/cyberstorm"; +import { TableSort } from "@thunderstore/cyberstorm/src/newComponents/Table/Table"; +import { type TeamServiceAccount } from "@thunderstore/thunderstore-api"; + +import { type OutletContextShape } from "app/root"; +import { ServiceAccountRemoveModal } from "./ServiceAccountRemoveModal"; +import "./ServiceAccounts.css"; + +const serviceAccountColumns = [ + { value: "Nickname", disableSort: false }, + { value: "Last Used", disableSort: false }, + { value: "Actions", disableSort: true }, +]; + +export function ServiceAccountsTable(props: { + serviceAccounts: TeamServiceAccount[]; + teamName: string; + serviceAccountRevalidate: () => Promise; +}) { + const { serviceAccounts, serviceAccountRevalidate, teamName } = props; + const outletContext = useOutletContext() as OutletContextShape; + + const tableData = serviceAccounts.map((serviceAccount) => { + return [ + { + value: ( +

+ {serviceAccount.name} +

+ ), + sortValue: serviceAccount.name, + }, + { + value: ( +

+ {serviceAccount.last_used ?? "Never"} +

+ ), + sortValue: serviceAccount.last_used ?? "0", + }, + { + value: ( + + ), + sortValue: 0, + }, + ]; + }); + + return ( + Service Accounts} + headers={serviceAccountColumns} + rows={tableData} + sortByHeader={1} + sortDirection={TableSort.ASC} + /> + ); +} + +ServiceAccountsTable.displayName = "ServiceAccountsTable"; From b7077e3f2ecb0873cf5c9d846e4321f465cd65b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Fri, 7 Nov 2025 16:20:56 +0200 Subject: [PATCH 4/5] Replace HydrateFallback with Suspense in team setting tabs Using await in clientLoader causes navigating between the tabs to do nothing until the clientLoader promise resolves. Removing the await forces the component to deal with the promises returned by the clientLoader, for which Suspense+Await components is a good pattern. Using it rendes HydarteFallback obsolete. All tabs use all-or-nothing rendering policy. For profile tab, only a form is rendered and it requires the resolved values. For Members and Service Account tabs we could consider rendering the sidebar and empty table initially, but I though it would be awkward for the add button to pop on the sidebar once the download finished (or alternatively a skeleton component to disappear). For Settings tab partial rendering could be considered, but that tab doesn't have an API endpoint available yet and shows only dummy data. On the other hand, for consistency, all-or-nothing strategy could be used there as well. --- .../teams/team/tabs/Members/Members.tsx | 62 +++++---- .../teams/team/tabs/Profile/Profile.tsx | 27 ++-- .../tabs/ServiceAccounts/ServiceAccounts.tsx | 61 +++++---- .../teams/team/tabs/Settings/Settings.tsx | 127 ++++++++++-------- 4 files changed, 155 insertions(+), 122 deletions(-) diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx index 87e0a1bb0..b35d9f478 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Members/Members.tsx @@ -1,4 +1,10 @@ -import { useLoaderData, useOutletContext, useRevalidator } from "react-router"; +import { Suspense } from "react"; +import { + Await, + useLoaderData, + useOutletContext, + useRevalidator, +} from "react-router"; import { type OutletContextShape } from "app/root"; import { makeTeamSettingsTabLoader } from "cyberstorm/utils/dapperClientLoaders"; @@ -8,14 +14,10 @@ import "./Members.css"; export const clientLoader = makeTeamSettingsTabLoader( async (dapper, teamName) => ({ - members: await dapper.getTeamMembers(teamName), + members: dapper.getTeamMembers(teamName), }) ); -export function HydrateFallback() { - return
Loading...
; -} - export default function Members() { const { teamName, members } = useLoaderData(); const outletContext = useOutletContext() as OutletContextShape; @@ -26,26 +28,32 @@ export default function Members() { } return ( -
-
-
-

Teams

-

Manage your teams

- -
-
- -
-
-
+ Loading...
}> + + {(resolvedMembers) => ( +
+
+
+

Teams

+

Manage your teams

+ +
+
+ +
+
+
+ )} +
+ ); } diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx index 09f55c4b0..d76552e27 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/Profile/Profile.tsx @@ -1,5 +1,10 @@ -import { useReducer } from "react"; -import { useLoaderData, useOutletContext, useRevalidator } from "react-router"; +import { Suspense, useReducer } from "react"; +import { + Await, + useLoaderData, + useOutletContext, + useRevalidator, +} from "react-router"; import { NewButton, NewTextInput, useToast } from "@thunderstore/cyberstorm"; import { @@ -18,21 +23,23 @@ export const clientLoader = makeTeamSettingsTabLoader( // TODO: for hygienie we shouldn't use this public endpoint but // have an endpoint that confirms user permissions and returns // possibly sensitive information. - team: await dapper.getTeamDetails(teamName), + team: dapper.getTeamDetails(teamName), }) ); -export function HydrateFallback() { - return
Loading...
; -} - export default function Profile() { const { team } = useLoaderData(); return ( -
- -
+ Loading...
}> + + {(resolvedTeam) => ( +
+ +
+ )} +
+ ); } diff --git a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx index 84bb49ca4..ad1fca880 100644 --- a/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx +++ b/apps/cyberstorm-remix/app/settings/teams/team/tabs/ServiceAccounts/ServiceAccounts.tsx @@ -1,7 +1,12 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useReducer, useState } from "react"; -import { useLoaderData, useOutletContext, useRevalidator } from "react-router"; +import { Suspense, useReducer, useState } from "react"; +import { + Await, + useLoaderData, + useOutletContext, + useRevalidator, +} from "react-router"; import { NewAlert, @@ -24,14 +29,10 @@ import "./ServiceAccounts.css"; export const clientLoader = makeTeamSettingsTabLoader( async (dapper, teamName) => ({ - serviceAccounts: await dapper.getTeamServiceAccounts(teamName), + serviceAccounts: dapper.getTeamServiceAccounts(teamName), }) ); -export function HydrateFallback() { - return
Loading...
; -} - export default function ServiceAccounts() { const { teamName, serviceAccounts } = useLoaderData(); const revalidator = useRevalidator(); @@ -41,25 +42,33 @@ export default function ServiceAccounts() { } return ( -
-
-
-

Service accounts

-

Your loyal servants

- -
-
- -
-
-
+ Loading...
}> + + {(resolvedServiceAccounts) => ( +
+
+
+

Service accounts

+

+ Your loyal servants +

+ +
+
+ +
+
+
+ )} +
+ ); } 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 c43c1ec15..588b0e25b 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 @@ -1,7 +1,12 @@ import { faTrashCan } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { useReducer, useState } from "react"; -import { useLoaderData, useNavigate, useOutletContext } from "react-router"; +import { Suspense, useReducer, useState } from "react"; +import { + Await, + useLoaderData, + useNavigate, + useOutletContext, +} from "react-router"; import "./Settings.css"; import { @@ -28,20 +33,18 @@ import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm"; export const clientLoader = makeTeamSettingsTabLoader( // TODO: add end point for checking can leave/disband status. - async (dapper, teamName) => ({}) + async (dapper, teamName) => ({ teamName }) ); export default function Settings() { const { teamName } = useLoaderData(); const outletContext = useOutletContext() as OutletContextShape; - - if (!outletContext.currentUser || !outletContext.currentUser.username) - return ; - const toast = useToast(); - const navigate = useNavigate(); + const currentUser = outletContext.currentUser?.username; + if (!currentUser) return ; + async function moveToTeams() { toast.addToast({ csVariant: "info", @@ -52,57 +55,63 @@ export default function Settings() { } return ( -
-
-
-

Leave team

-

Leave your 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. -

- -
-
-
-
-
-

Disband team

-

- Disband your 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. -

- -
-
-
+ Loading...
}> + + {(resolvedTeamName) => ( +
+
+
+

Leave team

+

Leave your 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. +

+ +
+
+
+
+
+

Disband team

+

+ Disband your 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. +

+ +
+
+
+ )} + + ); } From ff260e4d18079fdb58c8cf2d8ca3f3a47f82c670 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antti=20M=C3=A4ki?= Date: Thu, 20 Nov 2025 15:15:13 +0200 Subject: [PATCH 5/5] Add (hopefully shortlived) TODO comment --- .../cyberstorm/utils/dapperClientLoaders.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/cyberstorm-remix/cyberstorm/utils/dapperClientLoaders.ts b/apps/cyberstorm-remix/cyberstorm/utils/dapperClientLoaders.ts index 63fba0ab8..807439792 100644 --- a/apps/cyberstorm-remix/cyberstorm/utils/dapperClientLoaders.ts +++ b/apps/cyberstorm-remix/cyberstorm/utils/dapperClientLoaders.ts @@ -5,6 +5,21 @@ import { ApiError, type GenericApiError } from "@thunderstore/thunderstore-api"; import { getSessionTools } from "cyberstorm/security/publicEnvVariables"; +/** + * TODO + * 1) This approach no longer handles different ApiErrors properly + * when the data isn't awaited in the clientLoader but returned as + * promises for the Suspense/Await elements to handle. Instead, any + * HTTP error codes are shown as 500 errors. This isn't fixed yet + * as it will be easier to do once upcoming project wide error + * handling changes are merged. + * 2) The purpose of this helper was to reduce boilerplate in different + * tab components of the team settings page. Half of that boilerplate + * is Dapper setup, the other is handling ApiErrors. As the latter is + * supposed to be handled elsewhere after the changes mentioned above, + * this helper might no longer have a valid reason to exist after the + * changes. + */ export function makeTeamSettingsTabLoader( dataFetcher: (dapper: DapperTs, teamName: string) => Promise ) {