diff --git a/web/apps/admin/src/routes.tsx b/web/apps/admin/src/routes.tsx index ef93a0478..740a1c28e 100644 --- a/web/apps/admin/src/routes.tsx +++ b/web/apps/admin/src/routes.tsx @@ -31,6 +31,7 @@ import { OrganizationInvoicesView, OrganizationTokensView, OrganizationApisView, + OrganizationPatView, useAdminPaths, } from "@raystack/frontier/admin"; @@ -72,6 +73,7 @@ export default memo(function AppRoutes() { } /> } /> } /> + } /> }> } /> diff --git a/web/sdk/admin/index.ts b/web/sdk/admin/index.ts index 4127e1270..4ec41932e 100644 --- a/web/sdk/admin/index.ts +++ b/web/sdk/admin/index.ts @@ -21,6 +21,7 @@ export { OrganizationProjectsView } from "./views/organizations/details/projects export { OrganizationInvoicesView } from "./views/organizations/details/invoices"; export { OrganizationTokensView } from "./views/organizations/details/tokens"; export { OrganizationApisView } from "./views/organizations/details/apis"; +export { OrganizationPatView } from "./views/organizations/details/pat"; // context exports export { diff --git a/web/sdk/admin/views/organizations/details/layout/navbar.tsx b/web/sdk/admin/views/organizations/details/layout/navbar.tsx index 101c0bbb8..929a490d8 100644 --- a/web/sdk/admin/views/organizations/details/layout/navbar.tsx +++ b/web/sdk/admin/views/organizations/details/layout/navbar.tsx @@ -238,6 +238,7 @@ const NavLinks = ({ { name: "Invoices", path: `${basePath}/invoices` }, { name: "Tokens", path: `${basePath}/tokens` }, { name: "API", path: `${basePath}/apis` }, + { name: "PAT", path: `${basePath}/pat` }, { name: "Security", path: `${basePath}/security` }, ]; diff --git a/web/sdk/admin/views/organizations/details/pat/columns.tsx b/web/sdk/admin/views/organizations/details/pat/columns.tsx new file mode 100644 index 000000000..6ae66ee0b --- /dev/null +++ b/web/sdk/admin/views/organizations/details/pat/columns.tsx @@ -0,0 +1,133 @@ +import { + Avatar, + Flex, + getAvatarColor, + Text, + type DataTableColumnDef, +} from "@raystack/apsara"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import type { + Project, + SearchOrganizationPATsResponse_OrganizationPAT, +} from "@raystack/proton/frontier"; +import { + isNullTimestamp, + timestampToDayjs, +} from "~/admin/utils/connect-timestamp"; +import { SCOPES } from "~/admin/utils/constants"; +import { getInitials } from "~/utils"; +import styles from "./pat.module.css"; + +dayjs.extend(relativeTime); + +interface GetColumnsOptions { + projectsMap: Record; +} + +const DATE_FORMAT = "DD MMM YYYY"; + +export function getColumns({ + projectsMap, +}: GetColumnsOptions): DataTableColumnDef< + SearchOrganizationPATsResponse_OrganizationPAT, + unknown +>[] { + return [ + { + accessorKey: "title", + header: "Title", + classNames: { + cell: styles["first-column"], + header: styles["first-column"], + }, + cell: ({ getValue }) => { + const value = (getValue() as string) || ""; + return {value}; + }, + enableSorting: true, + enableColumnFilter: true, + }, + { + accessorKey: "scopes", + header: "Project", + classNames: { cell: styles["truncate-cell"] }, + enableSorting: false, + cell: ({ row }) => { + const projectScope = row.original.scopes?.find( + (scope) => scope.resourceType === SCOPES.PROJECT, + ); + const resourceIds = projectScope?.resourceIds ?? []; + if (resourceIds.length === 0) { + return -; + } + const projectNamesText = resourceIds.map( + (id) => projectsMap[id]?.title || projectsMap[id]?.name || id, + ).join(", "); + return {projectNamesText}; + }, + }, + { + accessorKey: "createdBy", + header: "Created By", + enableSorting: false, + enableColumnFilter: true, + enableHiding: true, + cell: ({ row }) => { + const createdBy = row.original.createdBy; + const userId = createdBy?.id || ""; + const title = createdBy?.title || createdBy?.email || userId || "-"; + const avatarColor = getAvatarColor(userId); + return ( + + + {title} + + ); + }, + }, + { + accessorKey: "createdAt", + header: "Created On", + styles: { header: { width: "152px" } }, + cell: ({ row }) => { + const date = timestampToDayjs(row.original.createdAt); + return date ? {date.format(DATE_FORMAT)} : -; + }, + enableSorting: true, + enableColumnFilter: true, + filterType: "date", + enableHiding: true + }, + { + accessorKey: "expiresAt", + header: "Expiry Date", + styles: { header: { width: "152px" } }, + cell: ({ row }) => { + const expiresAt = row.original.expiresAt; + if (!expiresAt || isNullTimestamp(expiresAt)) return -; + const date = timestampToDayjs(expiresAt); + return date ? {date.format(DATE_FORMAT)} : -; + }, + enableSorting: true, + enableColumnFilter: true, + filterType: "date", + enableHiding: true, + }, + { + accessorKey: "usedAt", + header: "Last used", + styles: { header: { width: "152px" } }, + enableSorting: false, + enableColumnFilter: true, + filterType: "date", + enableHiding: true, + cell: ({ row }) => { + const usedAt = row.original.usedAt; + if (!usedAt || isNullTimestamp(usedAt)) return -; + const date = timestampToDayjs(usedAt); + return date ? {date.fromNow()} : -; + }, + }, + ]; +} diff --git a/web/sdk/admin/views/organizations/details/pat/components/pat-details-dialog.module.css b/web/sdk/admin/views/organizations/details/pat/components/pat-details-dialog.module.css new file mode 100644 index 000000000..683463c28 --- /dev/null +++ b/web/sdk/admin/views/organizations/details/pat/components/pat-details-dialog.module.css @@ -0,0 +1,50 @@ +.details-dialog { + width: 100%; + max-width: 400px; + height: 500px; + display: flex; + flex-direction: column; +} + +.dialog-body { + flex: 1; + overflow: hidden; +} + +.tab-root { + gap: var(--rs-space-2); + height: 100%; + display: flex; + flex-direction: column; +} + +.tab-content { + flex: 1; + min-height: 0; + overflow: auto; +} + +.list-item { + padding: var(--rs-space-5) 0; + border-bottom: 0.5px solid var(--rs-color-border-base-primary); +} + +.list-item:last-child { + border-bottom: none; +} + +.list-item-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.skeleton { + flex: 1; + padding: var(--rs-space-5) 0; +} + +.empty-state { + height: 200px; +} diff --git a/web/sdk/admin/views/organizations/details/pat/components/pat-details-dialog.tsx b/web/sdk/admin/views/organizations/details/pat/components/pat-details-dialog.tsx new file mode 100644 index 000000000..a6f7d0cef --- /dev/null +++ b/web/sdk/admin/views/organizations/details/pat/components/pat-details-dialog.tsx @@ -0,0 +1,173 @@ +import { useMemo } from "react"; +import { + Dialog, + Flex, + Skeleton, + Tabs, + Text, +} from "@raystack/apsara"; +import { useQuery } from "@connectrpc/connect-query"; +import { + FrontierServiceQueries, + type Project, + type Role, + type SearchOrganizationPATsResponse_OrganizationPAT, +} from "@raystack/proton/frontier"; +import { useTerminology } from "~/admin/hooks/useTerminology"; +import { SCOPES } from "~/admin/utils/constants"; +import styles from "./pat-details-dialog.module.css"; + +interface PatDetailsDialogProps { + pat: SearchOrganizationPATsResponse_OrganizationPAT | null; + projectsMap: Record; + onClose: () => void; +} + +export function PatDetailsDialog({ + pat, + projectsMap, + onClose, +}: PatDetailsDialogProps) { + const t = useTerminology(); + const open = pat !== null; + + const projectScope = useMemo( + () => pat?.scopes?.find((s) => s.resourceType === SCOPES.PROJECT), + [pat], + ); + const orgScope = useMemo( + () => pat?.scopes?.find((s) => s.resourceType === SCOPES.ORG), + [pat], + ); + + const scopeProjectIds = useMemo(() => projectScope?.resourceIds ?? [], [projectScope]); + const scopeProjects = useMemo( + () => + scopeProjectIds + .map((id) => projectsMap[id]) + .filter((p): p is Project => Boolean(p)), + [scopeProjectIds, projectsMap], + ); + + const { data: rolesData, isLoading: isRolesLoading } = useQuery( + FrontierServiceQueries.listRolesForPAT, + { scopes: [SCOPES.ORG, SCOPES.PROJECT] }, + { enabled: open }, + ); + const rolesMap = useMemo(() => { + const map: Record = {}; + (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 ( + + + + {pat?.title || ""} + + + + + + + {t.project({ plural: true, case: "capital" })} ({projectCount}) + + + Roles ({roleEntries.length}) + + + + {scopeProjects.length === 0 ? null : ( + + {scopeProjects.map((project) => ( +
+ + {project.title || project.name} + +
+ ))} +
+ )} +
+ + {isRolesLoading ? ( + + ) : roleEntries.length === 0 ? ( + null + ) : ( + + {roleEntries.map((entry) => ( + + + {entry.roleTitle} + + + {entry.scopeLabel} + + + ))} + + )} + +
+
+
+
+ ); +} 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; +}