= {};
+ (rolesData?.roles ?? []).forEach((role) => {
+ if (role.id) map[role.id] = role;
+ });
+ return map;
+ }, [rolesData]);
+
+ const orgRole = orgScope?.roleId ? rolesMap[orgScope.roleId] : undefined;
+ const projectRole = projectScope?.roleId
+ ? rolesMap[projectScope.roleId]
+ : undefined;
+
+ const projectCount = scopeProjects.length;
+ const roleEntries = [
+ ...(orgScope
+ ? [
+ {
+ id: `org-${orgScope.roleId}`,
+ roleTitle: orgRole?.title || orgRole?.name || orgScope.roleId,
+ scopeLabel: t.organization({ case: "capital" }),
+ },
+ ]
+ : []),
+ ...(projectScope
+ ? [
+ {
+ id: `project-${projectScope.roleId}`,
+ roleTitle:
+ projectRole?.title || projectRole?.name || projectScope.roleId,
+ scopeLabel: t.project({ plural: true, case: "capital" }),
+ },
+ ]
+ : []),
+ ];
+
+ const onOpenChange = (val: boolean) => {
+ if (!val) onClose();
+ };
+
+ return (
+
+ );
+}
diff --git a/web/sdk/admin/views/organizations/details/pat/index.tsx b/web/sdk/admin/views/organizations/details/pat/index.tsx
new file mode 100644
index 000000000..455ea7b39
--- /dev/null
+++ b/web/sdk/admin/views/organizations/details/pat/index.tsx
@@ -0,0 +1,231 @@
+import { DataTable, EmptyState, Flex } from "@raystack/apsara";
+import type { DataTableQuery, DataTableSort } from "@raystack/apsara";
+import { LockClosedIcon, ExclamationTriangleIcon } from "@radix-ui/react-icons";
+import { useCallback, useContext, useEffect, useMemo, useState } from "react";
+import { useInfiniteQuery, useQuery } from "@connectrpc/connect-query";
+import {
+ AdminServiceQueries,
+ FrontierServiceQueries,
+ type Project,
+ type SearchOrganizationPATsResponse_OrganizationPAT,
+} from "@raystack/proton/frontier";
+import { useDebounceValue } from "usehooks-ts";
+import { OrganizationContext } from "../contexts/organization-context";
+import { PageTitle } from "~/admin/components/PageTitle";
+import {
+ DEFAULT_PAGE_SIZE,
+ getConnectNextPageParam,
+} from "~/utils/connect-pagination";
+import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query";
+import { useTerminology } from "~/admin/hooks/useTerminology";
+import { getColumns } from "./columns";
+import { PatDetailsDialog } from "./components/pat-details-dialog";
+import styles from "./pat.module.css";
+
+const DEFAULT_SORT: DataTableSort = { name: "createdAt", order: "desc" };
+const INITIAL_QUERY: DataTableQuery = {
+ offset: 0,
+ limit: DEFAULT_PAGE_SIZE,
+};
+const TRANSFORM_OPTIONS = {
+ fieldNameMapping: {
+ createdAt: "created_at",
+ updatedAt: "updated_at",
+ expiresAt: "expires_at",
+ usedAt: "used_at",
+ createdBy: "created_by_title",
+ },
+};
+
+const NoPats = () => {
+ return (
+ }
+ />
+ );
+};
+
+const ZeroState = () => {
+ return (
+
+ }
+ heading="PAT"
+ subHeading="Personal access tokens (PATs) provide programmatic access to organization resources via the API on behalf of a user."
+ />
+
+ );
+};
+
+const ErrorState = () => {
+ return (
+ }
+ />
+ );
+};
+
+export function OrganizationPatView() {
+ const t = useTerminology();
+ const { organization, search } = useContext(OrganizationContext);
+ const organizationId = organization?.id || "";
+ const {
+ onChange: onSearchChange,
+ setVisibility: setSearchVisibility,
+ query: searchQuery,
+ } = search;
+
+ const [tableQuery, setTableQuery] = useState(INITIAL_QUERY);
+ const [selectedPat, setSelectedPat] =
+ useState(null);
+
+ const title = `PAT | ${organization?.title} | ${t.organization({ plural: true, case: "capital" })}`;
+
+ const computedQuery = useMemo(() => {
+ const tempQuery = transformDataTableQueryToRQLRequest(
+ tableQuery,
+ TRANSFORM_OPTIONS,
+ );
+ return {
+ ...tempQuery,
+ search: searchQuery || "",
+ };
+ }, [tableQuery, searchQuery]);
+
+ const [query] = useDebounceValue(computedQuery, 200);
+
+ const {
+ data: infiniteData,
+ isLoading,
+ isFetchingNextPage,
+ fetchNextPage,
+ hasNextPage,
+ isError,
+ } = useInfiniteQuery(
+ AdminServiceQueries.searchOrganizationPATs,
+ { orgId: organizationId, query },
+ {
+ enabled: !!organizationId,
+ pageParamKey: "query",
+ getNextPageParam: (lastPage) =>
+ getConnectNextPageParam(lastPage, { query }, "organizationPats"),
+ staleTime: 0,
+ refetchOnWindowFocus: false,
+ retry: 1,
+ retryDelay: 1000,
+ },
+ );
+
+ const { data: projects = [] } = useQuery(
+ FrontierServiceQueries.listOrganizationProjects,
+ { id: organizationId, state: "", withMemberCount: false },
+ {
+ enabled: !!organizationId,
+ select: (data) => data?.projects || [],
+ },
+ );
+
+ const projectsMap = useMemo(
+ () =>
+ projects.reduce(
+ (acc, project) => {
+ if (project.id) acc[project.id] = project;
+ return acc;
+ },
+ {} as Record,
+ ),
+ [projects],
+ );
+
+ const data = useMemo(
+ () =>
+ infiniteData?.pages?.flatMap((page) => page?.organizationPats ?? []) ?? [],
+ [infiniteData],
+ );
+ const loading = (isLoading || isFetchingNextPage) && !isError;
+
+ const hasActiveQuery = Boolean(query.search || query.filters?.length);
+ const showZeroState =
+ !isLoading && !isError && !hasActiveQuery && data.length === 0;
+
+ const onTableQueryChange = (newQuery: DataTableQuery) => {
+ setTableQuery(newQuery);
+ };
+
+ const fetchMore = async () => {
+ if (hasNextPage && !isFetchingNextPage && !isError) {
+ await fetchNextPage();
+ }
+ };
+
+ useEffect(() => {
+ setSearchVisibility(true);
+ return () => {
+ onSearchChange("");
+ setSearchVisibility(false);
+ };
+ }, [setSearchVisibility, onSearchChange]);
+
+ const columns = useMemo(
+ () => getColumns({ projectsMap }),
+ [projectsMap],
+ );
+
+ const onRowClick = useCallback(
+ (row: SearchOrganizationPATsResponse_OrganizationPAT) => {
+ setSelectedPat(row);
+ },
+ [],
+ );
+
+ const onDialogClose = useCallback(() => {
+ setSelectedPat(null);
+ }, []);
+
+ return (
+
+
+
+
+
+
+ : isError ? : }
+ classNames={{
+ table: styles["table"],
+ root: styles["table-wrapper"],
+ header: styles["table-header"],
+ }}
+ />
+
+
+
+ );
+}
diff --git a/web/sdk/admin/views/organizations/details/pat/pat.module.css b/web/sdk/admin/views/organizations/details/pat/pat.module.css
new file mode 100644
index 000000000..46f8a0a59
--- /dev/null
+++ b/web/sdk/admin/views/organizations/details/pat/pat.module.css
@@ -0,0 +1,43 @@
+.empty-state {
+ height: 100%;
+}
+
+.zero-state-container {
+ padding: var(--rs-space-10);
+}
+
+.empty-state-subheading {
+ max-width: 360px;
+ text-wrap: auto;
+}
+
+.table {
+ width: 100%;
+ table-layout: fixed;
+}
+
+.table-wrapper {
+ /* Navbar Height + Toolbar height */
+ max-height: calc(100vh - 90px);
+ overflow: scroll;
+}
+
+.table-header {
+ z-index: 2;
+}
+
+.first-column {
+ padding-left: var(--rs-space-7);
+}
+
+.truncate-cell > * {
+ width: 100%;
+ min-width: 0;
+}
+
+.truncate-text {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}