(null);
+
+ useLayoutEffect(() => {
+ if (expanded || visibleCount !== null) return;
+ const container = containerRef.current;
+ if (!container) return;
+
+ const children = Array.from(container.children) as HTMLElement[];
+ if (children.length === 0) return;
+
+ let used = 0;
+ let count = 0;
+ for (let i = 0; i < children.length; i++) {
+ const w = children[i].offsetWidth;
+ const remaining = children.length - i - 1;
+ const reserve = remaining > 0 ? CHIP_GAP + COUNT_CHIP_WIDTH_RESERVE : 0;
+ const next = used + (count > 0 ? CHIP_GAP : 0) + w;
+ if (next + reserve > MAX_WIDTH) break;
+ used = next;
+ count++;
+ }
+
+ setVisibleCount(count > 0 ? count : 1);
+ }, [expanded, visibleCount, projects]);
+
+ if (expanded) {
+ return (
+
+ {projects.map(p => (
+ {p.title}
+ ))}
+
+ );
+ }
+
+ const visible =
+ visibleCount === null ? projects : projects.slice(0, visibleCount);
+ const hidden =
+ visibleCount === null ? 0 : projects.length - visibleCount;
+
+ return (
+
+ {visible.map(p => (
+ {p.title}
+ ))}
+ {hidden > 0 && (
+ setExpanded(true)}
+ ariaLabel={`Show ${hidden} more projects`}
+ data-test-id="frontier-sdk-pat-project-chips-expand-btn"
+ >
+ +{hidden}
+
+ )}
+
+ );
+}
diff --git a/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.module.css b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.module.css
new file mode 100644
index 000000000..7b5f56a38
--- /dev/null
+++ b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.module.css
@@ -0,0 +1,4 @@
+.body {
+ padding: var(--rs-space-9) var(--rs-space-7) !important;
+ gap: var(--rs-space-7) !important;
+}
diff --git a/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx
new file mode 100644
index 000000000..18c0aaf4b
--- /dev/null
+++ b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx
@@ -0,0 +1,155 @@
+'use client';
+
+import { useCallback, useState } from 'react';
+import { create } from '@bufbuild/protobuf';
+import { timestampFromDate } from '@bufbuild/protobuf/wkt';
+import { useMutation } from '@connectrpc/connect-query';
+import dayjs from 'dayjs';
+import {
+ FrontierServiceQueries,
+ RegenerateCurrentUserPATRequestSchema
+} from '@raystack/proton/frontier';
+import {
+ Button,
+ Dialog,
+ Flex,
+ Select,
+ Text,
+ toastManager
+} from '@raystack/apsara-v1';
+import { useFrontier } from '~/react/contexts/FrontierContext';
+import { DEFAULT_DATE_FORMAT } from '~/react/utils/constants';
+import { handleConnectError } from '~/utils/error';
+import { EXPIRY_OPTIONS } from '../utils';
+import styles from './regenerate-pat-dialog.module.css';
+
+export interface RegeneratePayload {
+ patId: string;
+ currentExpiryValue: string;
+}
+
+export interface RegeneratePATDialogProps {
+ handle: ReturnType>;
+ onRegenerated?: (token: string) => void;
+}
+
+export function RegeneratePATDialog({
+ handle,
+ onRegenerated
+}: RegeneratePATDialogProps) {
+ const { config } = useFrontier();
+ const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT;
+
+ const [expiryValue, setExpiryValue] = useState('');
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const { mutateAsync: regeneratePAT } = useMutation(
+ FrontierServiceQueries.regenerateCurrentUserPAT
+ );
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) {
+ setExpiryValue('');
+ }
+ };
+
+ const handleRegenerate = useCallback(
+ async (patId: string, selectedValue: string) => {
+ const option = EXPIRY_OPTIONS.find(o => o.value === selectedValue);
+ if (!option) return;
+
+ setIsSubmitting(true);
+ try {
+ const expiresAt = timestampFromDate(
+ dayjs().add(option.amount, option.unit).toDate()
+ );
+
+ const response = await regeneratePAT(
+ create(RegenerateCurrentUserPATRequestSchema, {
+ id: patId,
+ expiresAt
+ })
+ );
+
+ const token = response.pat?.token;
+ toastManager.add({
+ title: 'Token regenerated',
+ type: 'success'
+ });
+ handle.close();
+ setExpiryValue('');
+ if (token) onRegenerated?.(token);
+ } catch (error) {
+ handleConnectError(error, {
+ Default: err =>
+ toastManager.add({
+ title: 'Something went wrong',
+ description: err.message,
+ type: 'error'
+ })
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ },
+ [regeneratePAT, handle, onRegenerated]
+ );
+
+ return (
+
+ );
+}
diff --git a/web/sdk/react/views-new/pat/components/revoke-pat-dialog.module.css b/web/sdk/react/views-new/pat/components/revoke-pat-dialog.module.css
new file mode 100644
index 000000000..6742c54f4
--- /dev/null
+++ b/web/sdk/react/views-new/pat/components/revoke-pat-dialog.module.css
@@ -0,0 +1,3 @@
+.body {
+ border-bottom: none !important;
+}
diff --git a/web/sdk/react/views-new/pat/components/revoke-pat-dialog.tsx b/web/sdk/react/views-new/pat/components/revoke-pat-dialog.tsx
new file mode 100644
index 000000000..6cccf0e96
--- /dev/null
+++ b/web/sdk/react/views-new/pat/components/revoke-pat-dialog.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { useState } from 'react';
+import { create } from '@bufbuild/protobuf';
+import { useMutation } from '@connectrpc/connect-query';
+import {
+ FrontierServiceQueries,
+ DeleteCurrentUserPATRequestSchema
+} from '@raystack/proton/frontier';
+import { AlertDialog, Button, toastManager } from '@raystack/apsara-v1';
+import styles from './revoke-pat-dialog.module.css';
+import { handleConnectError } from '~/utils/error';
+
+export interface RevokePATDialogProps {
+ handle: ReturnType>;
+ onRevoked?: () => void;
+}
+
+export function RevokePATDialog({ handle, onRevoked }: RevokePATDialogProps) {
+ const [isLoading, setIsLoading] = useState(false);
+
+ const { mutateAsync: deletePAT } = useMutation(
+ FrontierServiceQueries.deleteCurrentUserPAT
+ );
+
+ const handleRevoke = async (patId: string) => {
+ setIsLoading(true);
+ try {
+ await deletePAT(
+ create(DeleteCurrentUserPATRequestSchema, { id: patId })
+ );
+ handle.close();
+ toastManager.add({ title: 'Token revoked', type: 'success' });
+ onRevoked?.();
+ } catch (error) {
+ handleConnectError(error, {
+ Default: err =>
+ toastManager.add({
+ title: 'Something went wrong',
+ description: err.message,
+ type: 'error'
+ })
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ {({ payload: patId }) => (
+
+
+ Revoke
+
+ This action cannot be undone. Revoking this token will
+ permanently remove access for any users using it. You'll
+ need to generate a new token if access is required again.
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/web/sdk/react/views-new/pat/components/token-cell.tsx b/web/sdk/react/views-new/pat/components/token-cell.tsx
new file mode 100644
index 000000000..340a7abf6
--- /dev/null
+++ b/web/sdk/react/views-new/pat/components/token-cell.tsx
@@ -0,0 +1,83 @@
+'use client';
+
+import { Button, Flex, Text } from '@raystack/apsara-v1';
+import styles from '../pat-view.module.css';
+
+function KeyIcon() {
+ return (
+
+ );
+}
+
+export interface TokenCellProps {
+ title: string;
+ expiry: string;
+ lastUsed: string;
+ onClick?: () => void;
+ onRevoke?: () => void;
+}
+
+export function TokenCell({ title, expiry, lastUsed, onClick, onRevoke }: TokenCellProps) {
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {expiry && (
+
+ {expiry}
+
+ )}
+ {expiry && lastUsed && (
+
+ •
+
+ )}
+ {lastUsed && (
+
+ {lastUsed}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/web/sdk/react/views-new/pat/index.ts b/web/sdk/react/views-new/pat/index.ts
new file mode 100644
index 000000000..f7ddb1865
--- /dev/null
+++ b/web/sdk/react/views-new/pat/index.ts
@@ -0,0 +1,3 @@
+export { PatsView } from './pat-view';
+export { PATDetailsView } from './pat-details-view';
+export type { PATDetailsViewProps } from './pat-details-view';
diff --git a/web/sdk/react/views-new/pat/pat-details-view.module.css b/web/sdk/react/views-new/pat/pat-details-view.module.css
new file mode 100644
index 000000000..425d3d19b
--- /dev/null
+++ b/web/sdk/react/views-new/pat/pat-details-view.module.css
@@ -0,0 +1,23 @@
+.section {
+ border: 1px solid var(--rs-color-border-base-primary);
+ border-radius: var(--rs-radius-2);
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: var(--rs-space-6);
+ padding: var(--rs-space-5);
+}
+
+.detailRow {
+ display: flex;
+ gap: var(--rs-space-3);
+ align-items: flex-start;
+}
+
+.menuContent {
+ min-width: 160px;
+}
+
+.callout {
+ width: 100%;
+}
diff --git a/web/sdk/react/views-new/pat/pat-details-view.tsx b/web/sdk/react/views-new/pat/pat-details-view.tsx
new file mode 100644
index 000000000..a3b812beb
--- /dev/null
+++ b/web/sdk/react/views-new/pat/pat-details-view.tsx
@@ -0,0 +1,387 @@
+'use client';
+
+import { ReactNode, useCallback, useEffect, useMemo } from 'react';
+import { DotsHorizontalIcon } from '@radix-ui/react-icons';
+import {
+ AlertDialog,
+ Breadcrumb,
+ Button,
+ Dialog,
+ Flex,
+ IconButton,
+ Menu,
+ Skeleton,
+ Text,
+ toastManager
+} from '@raystack/apsara-v1';
+import { useQuery } from '@connectrpc/connect-query';
+import { create } from '@bufbuild/protobuf';
+import {
+ FrontierServiceQueries,
+ GetCurrentUserPATRequestSchema,
+ ListRolesForPATRequestSchema,
+ ListOrganizationProjectsRequestSchema
+} from '@raystack/proton/frontier';
+import dayjs from 'dayjs';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import { useFrontier } from '../../contexts/FrontierContext';
+import { ViewContainer } from '../../components/view-container';
+import { ViewHeader } from '../../components/view-header';
+import { DEFAULT_DATE_FORMAT } from '../../utils/constants';
+import { PERMISSIONS } from '../../../utils';
+import { isNullTimestamp, timestampToDayjs } from '~/utils/timestamp';
+import {
+ PATCreatedDialog,
+ type PATCreatedPayload
+} from './components/pat-created-dialog';
+import { PATFormDialog } from './components/pat-form-dialog';
+import { PATProjectChips } from './components/pat-project-chips';
+import {
+ RegeneratePATDialog,
+ type RegeneratePayload
+} from './components/regenerate-pat-dialog';
+import { RevokePATDialog } from './components/revoke-pat-dialog';
+import { getExpiryOptionValue, getExpiryReferenceDayjs } from './utils';
+import styles from './pat-details-view.module.css';
+
+dayjs.extend(relativeTime);
+
+const updatePATDialogHandle = Dialog.createHandle();
+const regenerateDialogHandle = Dialog.createHandle();
+const patCreatedDialogHandle = Dialog.createHandle();
+const revokePATDialogHandle = AlertDialog.createHandle();
+
+interface DetailRowProps {
+ label: string;
+ children: ReactNode;
+}
+
+function DetailRow({ label, children }: DetailRowProps) {
+ return (
+
+ {label}
+ {typeof children === 'string' ? (
+
+ {children}
+
+ ) : (
+ children
+ )}
+
+ );
+}
+
+export interface PATDetailsViewProps {
+ patId: string;
+ onNavigateToPats?: () => void;
+ onDeleteSuccess?: () => void;
+}
+
+export function PATDetailsView({
+ patId,
+ onNavigateToPats,
+ onDeleteSuccess
+}: PATDetailsViewProps) {
+ const { activeOrganization: organization, config } = useFrontier();
+ const orgId = organization?.id || '';
+ const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT;
+
+ const {
+ data: pat,
+ isLoading: isPatLoading,
+ error: patError,
+ refetch: refetchPat
+ } = useQuery(
+ FrontierServiceQueries.getCurrentUserPAT,
+ create(GetCurrentUserPATRequestSchema, { id: patId }),
+ {
+ enabled: Boolean(patId),
+ select: d => d?.pat
+ }
+ );
+
+ useEffect(() => {
+ if (patError) {
+ toastManager.add({
+ title: 'Something went wrong',
+ description: patError.message,
+ type: 'error'
+ });
+ }
+ }, [patError]);
+
+ const { data: orgRolesData, isLoading: isOrgRolesLoading } = useQuery(
+ FrontierServiceQueries.listRolesForPAT,
+ create(ListRolesForPATRequestSchema, { scopes: [PERMISSIONS.OrganizationNamespace] }),
+ { enabled: Boolean(orgId) }
+ );
+ const orgRoles = useMemo(() => orgRolesData?.roles ?? [], [orgRolesData]);
+
+ const { data: projectRolesData, isLoading: isProjectRolesLoading } =
+ useQuery(
+ FrontierServiceQueries.listRolesForPAT,
+ create(ListRolesForPATRequestSchema, { scopes: [PERMISSIONS.ProjectNamespace] }),
+ { enabled: Boolean(orgId) }
+ );
+ const projectRoles = useMemo(
+ () => projectRolesData?.roles ?? [],
+ [projectRolesData]
+ );
+
+ const { data: projectsData, isLoading: isProjectsLoading } = useQuery(
+ FrontierServiceQueries.listOrganizationProjects,
+ create(ListOrganizationProjectsRequestSchema, {
+ id: orgId,
+ state: '',
+ withMemberCount: false
+ }),
+ { enabled: Boolean(orgId) }
+ );
+ const projects = useMemo(
+ () => projectsData?.projects ?? [],
+ [projectsData]
+ );
+
+ const isLoading =
+ !organization?.id ||
+ isPatLoading ||
+ isOrgRolesLoading ||
+ isProjectRolesLoading ||
+ isProjectsLoading;
+
+ const orgScope = useMemo(
+ () => pat?.scopes?.find(s => s.resourceType === PERMISSIONS.OrganizationNamespace),
+ [pat]
+ );
+
+ const projectScope = useMemo(
+ () => pat?.scopes?.find(s => s.resourceType === PERMISSIONS.ProjectNamespace),
+ [pat]
+ );
+
+ const orgRoleName = useMemo(() => {
+ if (!orgScope) return '';
+ const role = orgRoles.find(r => r.id === orgScope.roleId);
+ return role?.title || role?.name || '';
+ }, [orgScope, orgRoles]);
+
+ const projectRoleName = useMemo(() => {
+ if (!projectScope) return '';
+ const role = projectRoles.find(r => r.id === projectScope.roleId);
+ return role?.title || role?.name || '';
+ }, [projectScope, projectRoles]);
+
+ const scopeProjects = useMemo(() => {
+ if (!projectScope?.resourceIds?.length) return [];
+ return projects
+ .filter(p => projectScope.resourceIds.includes(p.id || ''))
+ .map(p => ({ id: p.id || '', title: p.title || p.id || '' }));
+ }, [projectScope, projects]);
+
+ const isAllProjects =
+ !projectScope?.resourceIds || projectScope.resourceIds.length === 0;
+
+ const createdOn = useMemo(() => {
+ const d = timestampToDayjs(pat?.createdAt);
+ return d ? d.format(dateFormat) : '';
+ }, [pat, dateFormat]);
+
+ const lastUsed = useMemo(() => {
+ if (!pat?.usedAt || isNullTimestamp(pat.usedAt)) return '';
+ const d = timestampToDayjs(pat.usedAt);
+ return d ? d.fromNow() : '';
+ }, [pat]);
+
+ const regeneratedOn = useMemo(() => {
+ if (!pat?.regeneratedAt || isNullTimestamp(pat.regeneratedAt)) return '';
+ const d = timestampToDayjs(pat.regeneratedAt);
+ return d ? d.format(dateFormat) : '';
+ }, [pat, dateFormat]);
+
+ const { expiryInfo, currentExpiryValue } = useMemo(() => {
+ const reference = getExpiryReferenceDayjs(pat);
+ const expires = timestampToDayjs(pat?.expiresAt);
+ if (!reference || !expires)
+ return { expiryInfo: '', currentExpiryValue: '' };
+ const days = expires.diff(reference, 'day');
+ return {
+ expiryInfo: `${expires.format(dateFormat)} (${days} Days)`,
+ currentExpiryValue: getExpiryOptionValue(reference, expires)
+ };
+ }, [pat, dateFormat]);
+
+ const handleRegenerated = useCallback(
+ (token: string) => {
+ patCreatedDialogHandle.openWithPayload({ token, isRegenerated: true });
+ },
+ []
+ );
+
+ const handleTokenDialogClose = () => {
+ refetchPat();
+ };
+
+ const patTitle = pat?.title || '';
+
+ return (
+
+
+ {
+ e.preventDefault();
+ onNavigateToPats?.();
+ }}
+ >
+ Personal access token
+
+
+
+ {isPatLoading ? (
+
+ ) : (
+ patTitle
+ )}
+
+
+ }
+ >
+
+
+
+ {isLoading ? (
+
+
+
+
+ ) : (
+ <>
+
+
+
+ General
+
+
+
+
+ {createdOn && {createdOn}}
+ {lastUsed && {lastUsed}}
+ {orgRoleName && (
+ {orgRoleName}
+ )}
+ {projectRoleName && (
+ {projectRoleName}
+ )}
+
+ {isAllProjects || scopeProjects.length === 0 ? (
+
+ All projects
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ Expiry Details
+
+
+
+
+ {expiryInfo && (
+ {expiryInfo}
+ )}
+ {regeneratedOn && (
+ {regeneratedOn}
+ )}
+
+
+ >
+ )}
+
+
+ refetchPat()}
+ />
+
+
+
+ );
+}
diff --git a/web/sdk/react/views-new/pat/pat-view.module.css b/web/sdk/react/views-new/pat/pat-view.module.css
new file mode 100644
index 000000000..0420a1152
--- /dev/null
+++ b/web/sdk/react/views-new/pat/pat-view.module.css
@@ -0,0 +1,24 @@
+.tableRoot {
+ border: none;
+}
+
+.revokeButton {
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+:global(tr):hover .revokeButton {
+ opacity: 1;
+}
+.projectChip {
+ max-width: var(--rs-space-16);
+ min-width: 0;
+ justify-content: flex-start;
+}
+
+.projectChipLabel {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/web/sdk/react/views-new/pat/pat-view.tsx b/web/sdk/react/views-new/pat/pat-view.tsx
new file mode 100644
index 000000000..6a4fb2460
--- /dev/null
+++ b/web/sdk/react/views-new/pat/pat-view.tsx
@@ -0,0 +1,241 @@
+'use client';
+
+import { useMemo } from 'react';
+import { LockClosedIcon } from '@radix-ui/react-icons';
+import {
+ AlertDialog,
+ Button,
+ DataTable,
+ Dialog,
+ EmptyState,
+ Flex,
+ Skeleton
+} from '@raystack/apsara-v1';
+import type { DataTableQuery, DataTableSort } from '@raystack/apsara-v1';
+import { useDebouncedState } from '@raystack/apsara-v1/hooks';
+import { useInfiniteQuery } from '@connectrpc/connect-query';
+import dayjs from 'dayjs';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import { FrontierServiceQueries } from '@raystack/proton/frontier';
+import { useFrontier } from '../../contexts/FrontierContext';
+import { useTerminology } from '../../hooks/useTerminology';
+import { ViewContainer } from '../../components/view-container';
+import { ViewHeader } from '../../components/view-header';
+import { DEFAULT_DATE_FORMAT } from '../../utils/constants';
+import {
+ DEFAULT_PAGE_SIZE,
+ getConnectNextPageParam
+} from '~/utils/connect-pagination';
+import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query';
+import { getColumns } from './components/pat-columns';
+import { PATFormDialog } from './components/pat-form-dialog';
+import {
+ PATCreatedDialog,
+ type PATCreatedPayload
+} from './components/pat-created-dialog';
+import { RevokePATDialog } from './components/revoke-pat-dialog';
+import styles from './pat-view.module.css';
+
+dayjs.extend(relativeTime);
+
+const createPATDialogHandle = Dialog.createHandle();
+const patCreatedDialogHandle = Dialog.createHandle();
+const revokePATDialogHandle = AlertDialog.createHandle();
+
+const DEFAULT_SORT: DataTableSort = { name: 'title', order: 'asc' };
+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'
+ }
+};
+
+export interface PatsViewProps {
+ onPATClick?: (patId: string) => void;
+}
+
+export function PatsView({ onPATClick }: PatsViewProps = {}) {
+ const {
+ activeOrganization: organization,
+ isActiveOrganizationLoading,
+ config
+ } = useFrontier();
+ const t = useTerminology();
+
+ const orgId = organization?.id ?? '';
+
+ const [tableQuery, setTableQuery] = useDebouncedState(
+ INITIAL_QUERY,
+ 200
+ );
+
+ const query = useMemo(
+ () => transformDataTableQueryToRQLRequest(tableQuery, TRANSFORM_OPTIONS),
+ [tableQuery]
+ );
+
+ const {
+ data: infiniteData,
+ isLoading: isPatsLoading,
+ isFetchingNextPage,
+ fetchNextPage,
+ hasNextPage,
+ refetch
+ } = useInfiniteQuery(
+ FrontierServiceQueries.searchCurrentUserPATs,
+ { orgId, query },
+ {
+ enabled: Boolean(orgId),
+ pageParamKey: 'query',
+ getNextPageParam: lastPage =>
+ getConnectNextPageParam(lastPage, { query }, 'pats'),
+ staleTime: 0,
+ refetchOnWindowFocus: false
+ }
+ );
+
+ const pats = useMemo(
+ () => infiniteData?.pages?.flatMap(page => page?.pats ?? []) ?? [],
+ [infiniteData]
+ );
+
+ const hasActiveQuery = Boolean(
+ tableQuery.search || tableQuery.filters?.length
+ );
+ const isInitialLoading = !organization?.id || isActiveOrganizationLoading;
+ const isTableLoading = isPatsLoading || isFetchingNextPage;
+ const hasNoPats =
+ !isInitialLoading &&
+ !isPatsLoading &&
+ !hasActiveQuery &&
+ pats.length === 0;
+
+ const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT;
+
+ const columns = useMemo(
+ () =>
+ getColumns({
+ dateFormat,
+ onRevoke: (patId: string) =>
+ revokePATDialogHandle.openWithPayload(patId)
+ }),
+ [dateFormat]
+ );
+
+ const onTableQueryChange = (newQuery: DataTableQuery) => {
+ setTableQuery({
+ ...newQuery,
+ offset: 0,
+ limit: newQuery.limit || DEFAULT_PAGE_SIZE
+ });
+ };
+
+ const handleLoadMore = async () => {
+ if (hasNextPage && !isFetchingNextPage) {
+ await fetchNextPage();
+ }
+ };
+
+ const handlePATCreated = (token: string) => {
+ patCreatedDialogHandle.openWithPayload({ token });
+ };
+
+ const handleSuccessDialogClose = () => {
+ refetch();
+ };
+
+ return (
+
+
+
+ {isInitialLoading ? (
+
+
+
+
+
+
+ ) : hasNoPats ? (
+ }
+ heading="No Personal Access Token Found"
+ subHeading={`Create a new to use the Keys of ${t.appName()} platform`}
+ primaryAction={
+
+ }
+ />
+ ) : (
+ onPATClick?.(row.id)}
+ >
+
+
+
+
+
+ }
+ heading="No tokens matching your search"
+ />
+ }
+ classNames={{
+ root: styles.tableRoot
+ }}
+ />
+
+
+ )}
+
+
+
+ refetch()}
+ />
+
+ );
+}
diff --git a/web/sdk/react/views-new/pat/utils.ts b/web/sdk/react/views-new/pat/utils.ts
new file mode 100644
index 000000000..1824b050e
--- /dev/null
+++ b/web/sdk/react/views-new/pat/utils.ts
@@ -0,0 +1,35 @@
+import dayjs, { type Dayjs } from 'dayjs';
+import type { PAT } from '@raystack/proton/frontier';
+import { isNullTimestamp, timestampToDayjs } from '~/utils/timestamp';
+
+export const EXPIRY_OPTIONS = [
+ { value: '1w', label: '1 week', amount: 1, unit: 'week' as const },
+ { value: '1m', label: '1 month', amount: 1, unit: 'month' as const },
+ { value: '3m', label: '3 months', amount: 3, unit: 'month' as const },
+ { value: '6m', label: '6 months', amount: 6, unit: 'month' as const },
+ { value: '12m', label: '12 months', amount: 12, unit: 'month' as const }
+] as const;
+
+export type ExpiryOption = (typeof EXPIRY_OPTIONS)[number];
+
+/**
+ * Reference timestamp for expiry math: regeneratedAt when present, else createdAt.
+ */
+export function getExpiryReferenceDayjs(pat?: PAT): Dayjs | null {
+ if (!pat) return null;
+ if (pat.regeneratedAt && !isNullTimestamp(pat.regeneratedAt)) {
+ return timestampToDayjs(pat.regeneratedAt);
+ }
+ return timestampToDayjs(pat.createdAt);
+}
+
+export function getExpiryOptionValue(
+ reference?: dayjs.Dayjs | null,
+ expiresAt?: dayjs.Dayjs | null
+): string {
+ if (!reference || !expiresAt) return '';
+ const match = EXPIRY_OPTIONS.find(option =>
+ reference.add(option.amount, option.unit).isSame(expiresAt, 'day')
+ );
+ return match?.value ?? '';
+}
diff --git a/web/sdk/admin/utils/connect-pagination.ts b/web/sdk/utils/connect-pagination.ts
similarity index 100%
rename from web/sdk/admin/utils/connect-pagination.ts
rename to web/sdk/utils/connect-pagination.ts
diff --git a/web/sdk/admin/utils/transform-query.ts b/web/sdk/utils/transform-query.ts
similarity index 100%
rename from web/sdk/admin/utils/transform-query.ts
rename to web/sdk/utils/transform-query.ts