From 6eaa16bd079718686c7f97462490620af3077704 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Mon, 16 Jun 2025 16:13:09 -0700 Subject: [PATCH 1/3] improvement: add pagination, search and column sort to service token table and improve table rows --- .../components/v2/Pagination/Pagination.tsx | 10 +- .../ServiceTokenSection.tsx | 30 +- .../ServiceTokenSection/ServiceTokenTable.tsx | 300 ++++++++++++++---- 3 files changed, 269 insertions(+), 71 deletions(-) diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index 417e450bd..c1f9306fa 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -70,7 +70,15 @@ export const Pagination = ({ key={`pagination-per-page-options-${perPageOption}`} icon={perPage === perPageOption && } iconPos="right" - onClick={() => onChangePerPage(perPageOption)} + onClick={() => { + const totalPages = Math.ceil(count / perPageOption); + + if (page > totalPages) { + onChangePage(totalPages); + } + + onChangePerPage(perPageOption); + }} > {perPageOption} rows per page diff --git a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx index e66ad17cd..712844e18 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; @@ -48,8 +48,29 @@ export const ServiceTokenSection = withProjectPermission( return (
-
-

Service Tokens

+
+
+
+

Service Tokens

+ +
+ + Docs + +
+
+
+

+ {t("section.token.service-tokens-description")} +

+
- Create token + Create Token )}
-

{t("section.token.service-tokens-description")}

void; }; +enum TokensOrderBy { + Name = "name", + Expiration = "expiration" +} + export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => { const { currentWorkspace } = useWorkspace(); const { data, isPending } = useGetUserWsServiceTokens({ workspaceID: currentWorkspace?.id || "" }); + const { + search, + setSearch, + setPage, + page, + perPage, + setPerPage, + offset, + orderDirection, + toggleOrderDirection, + orderBy, + setOrderDirection, + setOrderBy + } = usePagination(TokensOrderBy.Name, { + initPerPage: getUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, 20) + }); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, newPerPage); + }; + + const filteredTokens = useMemo( + () => + data + ?.filter((token) => { + const { name } = token; + + const searchValue = search.trim().toLowerCase(); + + return name.toLowerCase().includes(searchValue); + }) + .sort((a, b) => { + const [tokenOne, tokenTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; + + switch (orderBy) { + case TokensOrderBy.Expiration: + if (!tokenOne.expiresAt && !tokenTwo.expiresAt) return 0; + if (!tokenOne.expiresAt) return 1; + if (!tokenTwo.expiresAt) return -1; + + return ( + new Date(tokenOne.expiresAt).getTime() - new Date(tokenTwo.expiresAt).getTime() + ); + + case TokensOrderBy.Name: + default: + return tokenOne.name.toLowerCase().localeCompare(tokenTwo.name.toLowerCase()); + } + }) ?? [], + [data, orderDirection, search, orderBy] + ); + + useResetPageHelper({ + totalCount: filteredTokens.length, + offset, + setPage + }); + + const handleSort = (column: TokensOrderBy) => { + if (column === orderBy) { + toggleOrderDirection(); + return; + } + + setOrderBy(column); + setOrderDirection(OrderByDirection.ASC); + }; + + const getClassName = (col: TokensOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30"); + + const getColSortIcon = (col: TokensOrderBy) => + orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown; + return ( - - - - - - - - - - - {isPending && } - {!isPending && - data && - data.map((row) => ( - - - - - - - ))} - {!isPending && data && data?.length === 0 && ( +
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search service tokens..." + className="flex-1" + containerClassName="mb-4 mt-2" + /> + +
Token NameEnvironment - Secret PathValid Until -
{row.name} -
- {row?.scopes.map(({ secretPath, environment }) => ( -
-
{environment}
- - {secretPath} -
- ))} -
-
{row.expiresAt && new Date(row.expiresAt).toUTCString()} - - {(isAllowed) => ( - - handlePopUpOpen("deleteAPITokenConfirmation", { - name: row.name, - id: row.id - }) - } - colorSchema="danger" - ariaLabel="delete" - isDisabled={!isAllowed} - > - - - )} - -
+ - + + + + - )} - -
- - +
+ Name + handleSort(TokensOrderBy.Name)} + > + + +
+
Environment / Secret Path +
+ Valid Until + handleSort(TokensOrderBy.Expiration)} + > + + +
+
-
+ + + {isPending && } + {!isPending && + filteredTokens.slice(offset, perPage * page).map((row) => ( + + {row.name} + +
+ {row?.scopes.map(({ secretPath, environment }) => ( +
+
+ {environment} +
+ + {secretPath} +
+ ))} +
+ + + {row.expiresAt ? ( + format(row.expiresAt, "MM/dd/yyyy h:mm:ss aa") + ) : ( + N/A + )} + + + + + + + + + + + + {(isAllowed) => ( + } + isDisabled={!isAllowed} + onClick={(e) => { + e.stopPropagation(); + handlePopUpOpen("deleteAPITokenConfirmation", { + name: row.name, + id: row.id + }); + }} + > + Delete Token + + )} + + + + + + + ))} + + + {Boolean(filteredTokens.length) && ( + + )} + {!isPending && !filteredTokens?.length && ( + + )} + +
); }; From 796d6bfc85ac46f983034a3df0689de32d5ed2a8 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Mon, 16 Jun 2025 16:42:11 -0700 Subject: [PATCH 2/3] improvement: add scope handling to service token search --- .../ServiceTokenSection/ServiceTokenTable.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx index de08e99f1..4f364b6b6 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenTable.tsx @@ -94,11 +94,19 @@ export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => { () => data ?.filter((token) => { - const { name } = token; + const { name, scopes } = token; const searchValue = search.trim().toLowerCase(); - return name.toLowerCase().includes(searchValue); + if (name.toLowerCase().includes(searchValue)) { + return true; + } + + return scopes.some( + ({ environment, secretPath }) => + environment.toLowerCase().includes(searchValue) || + secretPath.toLowerCase().includes(searchValue) + ); }) .sort((a, b) => { const [tokenOne, tokenTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; @@ -148,7 +156,7 @@ export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search service tokens..." + placeholder="Search service tokens by name, environment or secret path..." className="flex-1" containerClassName="mb-4 mt-2" /> From 0a39e138a166848773d6900804f5384217eaf5ca Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Tue, 17 Jun 2025 14:10:48 -0700 Subject: [PATCH 3/3] fix: move service token form to separate component to prevent reset issue --- .../AddServiceTokenModal.tsx | 380 +++++++++--------- 1 file changed, 188 insertions(+), 192 deletions(-) diff --git a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx index d10f7be25..c869b03ec 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/AddServiceTokenModal.tsx @@ -67,13 +67,12 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void; }; -export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => { +const ServiceTokenForm = () => { const { t } = useTranslation(); const { currentWorkspace } = useWorkspace(); const { control, - reset, handleSubmit, formState: { isSubmitting } } = useForm({ @@ -152,13 +151,197 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => { } }; + return !hasServiceToken ? ( +
+ ( + + + + )} + /> + {tokenScopes.map(({ id }, index) => ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + remove(index)} + > + + +
+ ))} +
+ +
+ ( + + + + )} + /> + { + const options = [ + { + label: "Read (default)", + value: "read" + }, + { + label: "Write (optional)", + value: "write" + } + ] as const; + + return ( + + <> + {options.map(({ label, value: optionValue }) => { + return ( + { + onChange({ + ...value, + [optionValue]: state + }); + }} + > + {label} + + ); + })} + + + ); + }} + /> +
+ + + + +
+ + ) : ( +
+

{newToken}

+ + + + {t("common.click-to-copy")} + + +
+ ); +}; + +export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => { + const { t } = useTranslation(); + + const { currentWorkspace } = useWorkspace(); + return ( { handlePopUpToggle("createAPIToken", open); - reset(); - setToken(""); }} > { } subTitle={t("section.token.add-dialog.description") as string} > - {!hasServiceToken ? ( -
- ( - - - - )} - /> - {tokenScopes.map(({ id }, index) => ( -
- ( - - - - )} - /> - ( - - - - )} - /> - remove(index)} - > - - -
- ))} -
- -
- ( - - - - )} - /> - { - const options = [ - { - label: "Read (default)", - value: "read" - }, - { - label: "Write (optional)", - value: "write" - } - ] as const; - - return ( - - <> - {options.map(({ label, value: optionValue }) => { - return ( - { - onChange({ - ...value, - [optionValue]: state - }); - }} - > - {label} - - ); - })} - - - ); - }} - /> -
- - - - -
- - ) : ( -
-

{newToken}

- - - - {t("common.click-to-copy")} - - -
- )} +
);