From 20252e536e7090167536d546d795491ba77cab16 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 13 Apr 2026 03:05:31 +0530 Subject: [PATCH 1/5] feat: pat page --- web/apps/client-demo/src/Router.tsx | 4 + web/apps/client-demo/src/pages/Settings.tsx | 3 +- .../src/pages/settings/PatDetails.tsx | 17 + .../client-demo/src/pages/settings/Pats.tsx | 13 + web/sdk/react/index.ts | 1 + .../pat/components/pat-created-dialog.tsx | 75 +++ .../pat/components/pat-form-dialog.tsx | 518 ++++++++++++++++++ .../pat/components/regenerate-pat-dialog.tsx | 154 ++++++ .../pat/components/revoke-pat-dialog.tsx | 95 ++++ .../views-new/pat/components/token-cell.tsx | 83 +++ web/sdk/react/views-new/pat/index.ts | 3 + .../views-new/pat/pat-details-view.module.css | 35 ++ .../react/views-new/pat/pat-details-view.tsx | 351 ++++++++++++ .../react/views-new/pat/pat-view.module.css | 41 ++ web/sdk/react/views-new/pat/pat-view.tsx | 210 +++++++ 15 files changed, 1602 insertions(+), 1 deletion(-) create mode 100644 web/apps/client-demo/src/pages/settings/PatDetails.tsx create mode 100644 web/apps/client-demo/src/pages/settings/Pats.tsx create mode 100644 web/sdk/react/views-new/pat/components/pat-created-dialog.tsx create mode 100644 web/sdk/react/views-new/pat/components/pat-form-dialog.tsx create mode 100644 web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx create mode 100644 web/sdk/react/views-new/pat/components/revoke-pat-dialog.tsx create mode 100644 web/sdk/react/views-new/pat/components/token-cell.tsx create mode 100644 web/sdk/react/views-new/pat/index.ts create mode 100644 web/sdk/react/views-new/pat/pat-details-view.module.css create mode 100644 web/sdk/react/views-new/pat/pat-details-view.tsx create mode 100644 web/sdk/react/views-new/pat/pat-view.module.css create mode 100644 web/sdk/react/views-new/pat/pat-view.tsx diff --git a/web/apps/client-demo/src/Router.tsx b/web/apps/client-demo/src/Router.tsx index 854e6d62b..17941416e 100644 --- a/web/apps/client-demo/src/Router.tsx +++ b/web/apps/client-demo/src/Router.tsx @@ -21,6 +21,8 @@ import Teams from './pages/settings/Teams'; import TeamDetails from './pages/settings/TeamDetails'; import ServiceAccounts from './pages/settings/ServiceAccounts'; import ServiceAccountDetails from './pages/settings/ServiceAccountDetails'; +import Pats from './pages/settings/Pats'; +import PatDetails from './pages/settings/PatDetails'; function Router() { return ( @@ -48,6 +50,8 @@ function Router() { } /> } /> } /> + } /> + } /> } /> diff --git a/web/apps/client-demo/src/pages/Settings.tsx b/web/apps/client-demo/src/pages/Settings.tsx index eea311069..b54dfc456 100644 --- a/web/apps/client-demo/src/pages/Settings.tsx +++ b/web/apps/client-demo/src/pages/Settings.tsx @@ -13,7 +13,8 @@ const NAV_ITEMS = [ { label: 'Projects', path: 'projects' }, { label: 'Tokens', path: 'tokens' }, { label: 'Teams', path: 'teams' }, - { label: 'Service Accounts', path: 'service-accounts' } + { label: 'Service Accounts', path: 'service-accounts' }, + { label: 'Personal Access Tokens', path: 'pats' } ]; export default function Settings() { diff --git a/web/apps/client-demo/src/pages/settings/PatDetails.tsx b/web/apps/client-demo/src/pages/settings/PatDetails.tsx new file mode 100644 index 000000000..51a2073ca --- /dev/null +++ b/web/apps/client-demo/src/pages/settings/PatDetails.tsx @@ -0,0 +1,17 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { PATDetailsView } from '@raystack/frontier/react'; + +export default function PatDetails() { + const { orgId, patId } = useParams<{ orgId: string; patId: string }>(); + const navigate = useNavigate(); + + if (!patId) return null; + + return ( + navigate(`/${orgId}/settings/pats`)} + onDeleteSuccess={() => navigate(`/${orgId}/settings/pats`)} + /> + ); +} diff --git a/web/apps/client-demo/src/pages/settings/Pats.tsx b/web/apps/client-demo/src/pages/settings/Pats.tsx new file mode 100644 index 000000000..1848ef878 --- /dev/null +++ b/web/apps/client-demo/src/pages/settings/Pats.tsx @@ -0,0 +1,13 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import { PatsView } from '@raystack/frontier/react'; + +export default function Pats() { + const { orgId } = useParams<{ orgId: string }>(); + const navigate = useNavigate(); + + return ( + navigate(`/${orgId}/settings/pats/${patId}`)} + /> + ); +} diff --git a/web/sdk/react/index.ts b/web/sdk/react/index.ts index 6b93bfb7a..5f5ea4c41 100644 --- a/web/sdk/react/index.ts +++ b/web/sdk/react/index.ts @@ -43,6 +43,7 @@ export { ServiceAccountsView, ServiceAccountDetailsView } from './views-new/service-accounts'; +export { PatsView, PATDetailsView } from './views-new/pat'; export type { FrontierClientOptions, diff --git a/web/sdk/react/views-new/pat/components/pat-created-dialog.tsx b/web/sdk/react/views-new/pat/components/pat-created-dialog.tsx new file mode 100644 index 000000000..21f04da67 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/pat-created-dialog.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { InfoCircledIcon } from '@radix-ui/react-icons'; +import { + Button, + Callout, + CopyButton, + Dialog, + Flex, + InputField, + Text +} from '@raystack/apsara-v1'; +import styles from '../pat-view.module.css'; + +export interface PATCreatedDialogProps { + handle: ReturnType>; + onClose?: () => void; +} + +export function PATCreatedDialog({ handle, onClose }: PATCreatedDialogProps) { + const handleOpenChange = (open: boolean) => { + if (!open) onClose?.(); + }; + + return ( + + {({ payload: token }) => ( + + + Success + + + + + You've successfully added a new personal access token. Copy + the token now + + + ) : undefined + } + data-test-id="frontier-sdk-pat-token-input" + /> + } className={styles.callout}> + Warning : Make sure you copy the above token now. We don't + store it and you will not be able to see it again. + + + + + + + + + + )} + + ); +} diff --git a/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx b/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx new file mode 100644 index 000000000..cf39152e4 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx @@ -0,0 +1,518 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { orderBy } from 'lodash'; +import { create } from '@bufbuild/protobuf'; +import { timestampFromDate } from '@bufbuild/protobuf/wkt'; +import { useMutation, useQuery } from '@connectrpc/connect-query'; +import dayjs from 'dayjs'; +import { + FrontierServiceQueries, + CreateCurrentUserPATRequestSchema, + UpdateCurrentUserPATRequestSchema, + CheckCurrentUserPATTitleRequestSchema, + ListRolesForPATRequestSchema, + ListOrganizationProjectsRequestSchema +} from '@raystack/proton/frontier'; +import type { PAT } from '@raystack/proton/frontier'; +import { + Button, + Dialog, + Flex, + InputField, + Label, + Select, + Skeleton, + Text, + toastManager +} from '@raystack/apsara-v1'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { DEFAULT_DATE_FORMAT } from '../../../utils/constants'; +import { PERMISSIONS } from '../../../../utils'; +import { handleConnectError } from '~/utils/error'; + +const EXPIRY_OPTIONS = [15, 30, 60, 90] as const; + +const baseFields = { + title: yup.string().required('Name is required'), + orgRoleId: yup.string().required('Organization role is required'), + projectRoleId: yup.string().required('Project role is required'), + projectIds: yup + .array() + .of(yup.string().required()) + .min(1, 'At least one project is required') + .required('Project is a required field') +}; + +const createPATSchema = yup + .object({ + ...baseFields, + expiryDays: yup.string().required('Expiry date is required') + }) + .required(); + +const updatePATSchema = yup + .object({ + ...baseFields, + expiryDays: yup.string().default('') + }) + .required(); + +type FormData = yup.InferType; + +export interface PATFormDialogProps { + handle: ReturnType; + initialData?: PAT; + onCreated?: (token: string) => void; + onUpdated?: () => void; +} + +export function PATFormDialog({ + handle, + initialData, + onCreated, + onUpdated +}: PATFormDialogProps) { + const { activeOrganization: organization, config } = useFrontier(); + const orgId = organization?.id || ''; + const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT; + + const isUpdateMode = Boolean(initialData); + + const { + register, + control, + handleSubmit, + reset, + getValues, + formState: { errors, isSubmitting, isDirty } + } = useForm({ + resolver: yupResolver(isUpdateMode ? updatePATSchema : createPATSchema), + defaultValues: { + title: '', + expiryDays: '', + orgRoleId: '', + projectRoleId: '', + projectIds: [] + } + }); + + // null = unchecked, true = available, false = taken + const [titleAvailable, setTitleAvailable] = useState(null); + const [titleChecking, setTitleChecking] = useState(false); + + const handleOpenChange = (open: boolean) => { + if (open && initialData) { + const orgScope = initialData.scopes?.find( + s => s.resourceType === PERMISSIONS.OrganizationNamespace + ); + const projectScope = initialData.scopes?.find( + s => s.resourceType === PERMISSIONS.ProjectNamespace + ); + const validProjectIds = (projectScope?.resourceIds || []).filter(id => + projects.some(p => p.id === id) + ); + reset({ + title: initialData.title, + expiryDays: '', + orgRoleId: orgScope?.roleId || '', + projectRoleId: projectScope?.roleId || '', + projectIds: validProjectIds + }); + setTitleAvailable(true); + } + if (!open) { + reset(); + setTitleAvailable(null); + } + }; + + const { data: projectsData, isLoading: isProjectsLoading } = useQuery( + FrontierServiceQueries.listOrganizationProjects, + create(ListOrganizationProjectsRequestSchema, { + id: orgId, + state: '', + withMemberCount: false + }), + { enabled: Boolean(orgId) } + ); + + const projects = useMemo(() => { + const list = projectsData?.projects ?? []; + return orderBy(list, ['title'], ['asc']); + }, [projectsData]); + + 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 { mutateAsync: createPAT } = useMutation( + FrontierServiceQueries.createCurrentUserPAT + ); + + const { mutateAsync: updatePAT } = useMutation( + FrontierServiceQueries.updateCurrentUserPAT + ); + + const { mutateAsync: checkTitle } = useMutation( + FrontierServiceQueries.checkCurrentUserPATTitle + ); + + const handleTitleBlur = useCallback(async () => { + const title = getValues('title'); + if (!title || !orgId) return; + + // In update mode, skip check if title is unchanged + if (isUpdateMode && title === initialData?.title) { + setTitleAvailable(true); + return; + } + + setTitleChecking(true); + try { + const result = await checkTitle( + create(CheckCurrentUserPATTitleRequestSchema, { orgId, title }) + ); + setTitleAvailable(result?.available); + } catch { + // Ignore check failure — don't block the user + } finally { + setTitleChecking(false); + } + }, [getValues, orgId, checkTitle, isUpdateMode, initialData]); + + const titleField = register('title'); + + const onSubmit = useCallback( + async (data: FormData) => { + if (!orgId) return; + + const scopes = [ + { + roleId: data.orgRoleId, + resourceType: PERMISSIONS.OrganizationNamespace, + resourceIds: [] as string[] + }, + { + roleId: data.projectRoleId, + resourceType: PERMISSIONS.ProjectNamespace, + resourceIds: data.projectIds + } + ]; + + try { + if (isUpdateMode && initialData) { + await updatePAT( + create(UpdateCurrentUserPATRequestSchema, { + id: initialData.id, + title: data.title, + scopes + }) + ); + toastManager.add({ + title: 'Personal access token updated', + type: 'success' + }); + handle.close(); + reset(); + onUpdated?.(); + } else { + const expiresAt = timestampFromDate( + dayjs().add(Number(data.expiryDays), 'day').toDate() + ); + const response = await createPAT( + create(CreateCurrentUserPATRequestSchema, { + title: data.title, + orgId, + scopes, + expiresAt + }) + ); + const token = response.pat?.token; + toastManager.add({ + title: 'Personal access token created', + type: 'success' + }); + handle.close(); + reset(); + if (token) onCreated?.(token); + } + } catch (error) { + handleConnectError(error, { + AlreadyExists: () => + toastManager.add({ + title: 'A token with this name already exists', + type: 'error' + }), + Default: err => + toastManager.add({ + title: 'Something went wrong', + description: err.message, + type: 'error' + }) + }); + } + }, + [ + orgId, + isUpdateMode, + initialData, + createPAT, + updatePAT, + handle, + reset, + onCreated, + onUpdated + ] + ); + + const isDataLoading = + isProjectsLoading || isOrgRolesLoading || isProjectRolesLoading; + + return ( + + +
+ + + {isUpdateMode ? 'Update PAT' : 'Create new PAT'} + + + + + {isDataLoading ? ( + + + + + + {!isUpdateMode && } + + ) : ( + <> + { + titleField.onChange(e); + if ( + isUpdateMode && + e.target.value === initialData?.title + ) { + setTitleAvailable(true); + } else { + setTitleAvailable(null); + } + }} + onBlur={async e => { + titleField.onBlur(e); + await handleTitleBlur(); + }} + size="large" + placeholder="Enter token name" + error={ + errors.title + ? String(errors.title?.message) + : titleAvailable === false + ? 'This name is already taken' + : undefined + } + /> + + {!isUpdateMode && ( + + + ( + + )} + /> + {errors.expiryDays && ( + + {String(errors.expiryDays?.message)} + + )} + + )} + + + + ( + + )} + /> + {errors.orgRoleId && ( + + {String(errors.orgRoleId?.message)} + + )} + + + + + ( + + )} + /> + {errors.projectRoleId && ( + + {String(errors.projectRoleId?.message)} + + )} + + + + + ( + + )} + /> + {errors.projectIds && ( + + {String(errors.projectIds?.message)} + + )} + + + )} + + + + + + + + +
+
+
+ ); +} 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..66823207f --- /dev/null +++ b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx @@ -0,0 +1,154 @@ +'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, + Label, + Select, + Text, + toastManager +} from '@raystack/apsara-v1'; +import { useFrontier } from '../../../contexts/FrontierContext'; +import { DEFAULT_DATE_FORMAT } from '../../../utils/constants'; +import { handleConnectError } from '~/utils/error'; + +const EXPIRY_OPTIONS = [15, 30, 60, 90] as const; + +export interface RegeneratePayload { + patId: string; + currentExpiryDays: 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 [expiryDays, setExpiryDays] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { mutateAsync: regeneratePAT } = useMutation( + FrontierServiceQueries.regenerateCurrentUserPAT + ); + + const handleOpenChange = (open: boolean) => { + if (!open) { + setExpiryDays(''); + } + }; + + const handleRegenerate = useCallback(async () => { + const days = expiryDays || handle.payload?.currentExpiryDays; + if (!days) return; + + setIsSubmitting(true); + try { + const expiresAt = timestampFromDate( + dayjs().add(Number(days), 'day').toDate() + ); + + const patId = handle.payload?.patId; + if (!patId) return; + + const response = await regeneratePAT( + create(RegenerateCurrentUserPATRequestSchema, { + id: patId, + expiresAt + }) + ); + + const token = response.pat?.token; + toastManager.add({ + title: 'Token regenerated', + type: 'success' + }); + handle.close(); + setExpiryDays(''); + if (token) onRegenerated?.(token); + } catch (error) { + handleConnectError(error, { + Default: err => + toastManager.add({ + title: 'Something went wrong', + description: err.message, + type: 'error' + }) + }); + } finally { + setIsSubmitting(false); + } + }, [expiryDays, handle, regeneratePAT, onRegenerated]); + + return ( + + {({ payload }) => { + const selectedDays = expiryDays || payload?.currentExpiryDays || ''; + return ( + + + Regenerate Expiry date + + + + + Select a new expiry duration for this personal access token. + The current token will be invalidated and a new one will be + generated. + + + + + + + + + + + + + + ); + }} + + ); +} 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..3a1922943 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/revoke-pat-dialog.tsx @@ -0,0 +1,95 @@ +'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, + Flex, + Text, + toastManager +} from '@raystack/apsara-v1'; +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 is an irreversible action, doing this might lead to permanent + deletion of the data. Do you wish to proceed? + + + + + + + + + + )} + + ); +} 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..db8225bb5 --- /dev/null +++ b/web/sdk/react/views-new/pat/pat-details-view.module.css @@ -0,0 +1,35 @@ +.section { + border: 1px solid var(--rs-color-border-base-primary); + border-radius: 4px; + width: 100%; +} + +.sectionHeader { + padding: var(--rs-space-5); +} + +.sectionBody { + padding: 0 var(--rs-space-5) var(--rs-space-5); +} + +.detailRow { + display: flex; + gap: 8px; + align-items: flex-start; +} + +.detailLabel { + flex-shrink: 0; + min-width: 120px; +} + +.chipGroup { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} + +.menuContent { + min-width: 160px; +} 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..eb91cd248 --- /dev/null +++ b/web/sdk/react/views-new/pat/pat-details-view.tsx @@ -0,0 +1,351 @@ +'use client'; + +import { useCallback, useEffect, useMemo } from 'react'; +import { DotsHorizontalIcon } from '@radix-ui/react-icons'; +import { + AlertDialog, + Breadcrumb, + Button, + Chip, + 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 { 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 { timestampToDayjs } from '../../../utils/timestamp'; +import { PATCreatedDialog } from './components/pat-created-dialog'; +import { PATFormDialog } from './components/pat-form-dialog'; +import { + RegeneratePATDialog, + type RegeneratePayload +} from './components/regenerate-pat-dialog'; +import { RevokePATDialog } from './components/revoke-pat-dialog'; +import styles from './pat-details-view.module.css'; + +const updatePATDialogHandle = Dialog.createHandle(); +const regenerateDialogHandle = Dialog.createHandle(); +const patCreatedDialogHandle = Dialog.createHandle(); +const revokePATDialogHandle = AlertDialog.createHandle(); + +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 || '') + ); + }, [projectScope, projects]); + + const createdOn = useMemo(() => { + const d = timestampToDayjs(pat?.createdAt); + return d ? d.format(dateFormat) : ''; + }, [pat, dateFormat]); + + const { expiryInfo, expiryDays } = useMemo(() => { + const created = timestampToDayjs(pat?.createdAt); + const expires = timestampToDayjs(pat?.expiresAt); + if (!created || !expires) return { expiryInfo: '', expiryDays: '' }; + const days = expires.diff(created, 'day'); + return { + expiryInfo: `${days} Days (Exp: ${expires.format(dateFormat)})`, + expiryDays: String(days) + }; + }, [pat, dateFormat]); + + const handleRegenerated = useCallback( + (token: string) => { + patCreatedDialogHandle.openWithPayload(token); + }, + [] + ); + + const handleTokenDialogClose = () => { + refetchPat(); + }; + + const patTitle = pat?.title || ''; + + return ( + + + { + e.preventDefault(); + onNavigateToPats?.(); + }} + > + Personal access token + + + + {isPatLoading ? ( + + ) : ( + patTitle + )} + + + } + > + + + } + > + + + + revokePATDialogHandle.openWithPayload(patId)} + style={{ color: 'var(--rs-color-foreground-danger-primary)' }} + data-test-id="frontier-sdk-pat-revoke-menu-btn" + > + Revoke + + + + + + {isLoading ? ( + + + + + ) : ( + <> +
+ + + General + + + + + {orgRoleName && ( +
+ + Organization role : + + + {orgRoleName} + +
+ )} + {projectRoleName && ( +
+ + Project role: + + + {projectRoleName} + +
+ )} + {scopeProjects.length > 0 && ( +
+ + Projects + +
+ {scopeProjects.map(project => ( + + {project.title} + + ))} +
+
+ )} +
+
+ +
+ + + + Expiry Date + + + + {createdOn && ( + Created on: {createdOn} + )} + {expiryInfo && {expiryInfo}} + +
+ + )} + + + 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..dd9c78b6b --- /dev/null +++ b/web/sdk/react/views-new/pat/pat-view.module.css @@ -0,0 +1,41 @@ +.tokenList { + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + width: 100%; +} + +.tokenListHeader { + padding: var(--rs-space-5); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); +} + +.tokenCell { + padding: var(--rs-space-5); + border-bottom: 0.5px solid var(--rs-color-border-base-primary); + width: 100%; +} + +.tokenCell:last-child { + border-bottom: none; +} + +.callout { + flex: 1; + width: 100%; + box-sizing: border-box; +} + +.searchInput { + max-width: 360px; +} + +.tokenIcon { + width: 48px; + height: 48px; + border-radius: var(--rs-space-2); + background-color: var(--rs-color-background-neutral-secondary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} 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..602c6376e --- /dev/null +++ b/web/sdk/react/views-new/pat/pat-view.tsx @@ -0,0 +1,210 @@ +'use client'; + +import { useMemo } from 'react'; +import { LockClosedIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons'; +import { useDebouncedState } from '@raystack/apsara-v1/hooks'; +import { + AlertDialog, + Button, + Dialog, + EmptyState, + Flex, + InputField, + Skeleton, + Text +} from '@raystack/apsara-v1'; +import { useQuery } from '@connectrpc/connect-query'; +import { create } from '@bufbuild/protobuf'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { + FrontierServiceQueries, + SearchCurrentUserPATsRequestSchema, + RQLRequestSchema +} from '@raystack/proton/frontier'; +import type { PAT } 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 { timestampToDayjs, isNullTimestamp } from '../../../utils/timestamp'; +import { TokenCell } from './components/token-cell'; +import { PATFormDialog } from './components/pat-form-dialog'; +import { PATCreatedDialog } 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(); + +export interface PatsViewProps { + onPATClick?: (patId: string) => void; +} + +export function PatsView({ onPATClick }: PatsViewProps = {}) { + const { + activeOrganization: organization, + isActiveOrganizationLoading, + config + } = useFrontier(); + const t = useTerminology(); + + const [debouncedSearch, setDebouncedSearch] = useDebouncedState('', 300); + + const orgId = organization?.id ?? ''; + + const rqlQuery = useMemo( + () => create(RQLRequestSchema, { search: debouncedSearch || '' }), + [debouncedSearch] + ); + + const { + data: patsData, + isLoading: isPatsLoading, + refetch + } = useQuery( + FrontierServiceQueries.searchCurrentUserPATs, + create(SearchCurrentUserPATsRequestSchema, { + orgId, + query: rqlQuery + }), + { + enabled: Boolean(orgId) + } + ); + + const pats = useMemo(() => patsData?.pats ?? [], [patsData]); + + const isLoading = !organization?.id || isActiveOrganizationLoading || isPatsLoading; + const hasNoPats = !isLoading && pats.length === 0 && !debouncedSearch.trim(); + + const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT; + + const formatExpiry = (pat: PAT): string => { + const d = timestampToDayjs(pat.expiresAt); + return d ? `Exp: ${d.format(dateFormat)}` : ''; + }; + + const formatLastUsed = (pat: PAT): string => { + if (!pat.lastUsedAt || isNullTimestamp(pat.lastUsedAt)) return ''; + const d = timestampToDayjs(pat.lastUsedAt); + return d ? `Last used ${d.fromNow()}` : ''; + }; + + const handlePATCreated = (token: string) => { + patCreatedDialogHandle.openWithPayload(token); + }; + + const handleSuccessDialogClose = () => { + refetch(); + }; + + return ( + + + + {isLoading ? ( + + + + + + + ) : hasNoPats ? ( + } + heading="No Personal Access Token Found" + subHeading={`Create a new to use the Keys of ${t.appName()} platform`} + primaryAction={ + + } + /> + ) : ( + <> + } + onChange={e => setDebouncedSearch(e.target.value)} + className={styles.searchInput} + data-test-id="frontier-sdk-pat-search-input" + /> + +
+ + + {pats.length}{' '} + {pats.length === 1 ? 'Token' : 'Tokens'} + + + + + {pats.length === 0 ? ( + + + No tokens matching your search + + + ) : ( + pats.map(pat => ( + onPATClick?.(pat.id)} + onRevoke={() => + revokePATDialogHandle.openWithPayload(pat.id) + } + /> + )) + )} +
+ + )} + + + + refetch()} + /> +
+ ); +} From 353427ca90b808acb5b5484a6cf7f859034afd9c Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 21 Apr 2026 16:38:11 +0530 Subject: [PATCH 2/5] feata: update pat design --- .../views-new/pat/components/pat-columns.tsx | 67 ++++++++ .../pat/components/pat-form-dialog.tsx | 146 +++++++++++++----- .../react/views-new/pat/pat-view.module.css | 43 +----- web/sdk/react/views-new/pat/pat-view.tsx | 124 ++++++--------- 4 files changed, 231 insertions(+), 149 deletions(-) create mode 100644 web/sdk/react/views-new/pat/components/pat-columns.tsx diff --git a/web/sdk/react/views-new/pat/components/pat-columns.tsx b/web/sdk/react/views-new/pat/components/pat-columns.tsx new file mode 100644 index 000000000..3c6d73394 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/pat-columns.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { Button, Text, DataTableColumnDef } from '@raystack/apsara-v1'; +import type { PAT } from '@raystack/proton/frontier'; +import { timestampToDayjs, isNullTimestamp } from '../../../../utils/timestamp'; +import styles from '../pat-view.module.css'; + +export function getColumns({ + dateFormat, + onRevoke +}: { + dateFormat: string; + onRevoke: (patId: string) => void; +}): DataTableColumnDef[] { + return [ + { + header: 'Title', + accessorKey: 'title', + cell: ({ getValue }) => ( + {getValue() as string} + ) + }, + { + header: 'Expiry Date', + accessorKey: 'expiresAt', + enableSorting: false, + cell: ({ row }) => { + const d = timestampToDayjs(row.original.expiresAt); + return d ? {d.format(dateFormat)} : null; + } + }, + { + header: 'Last used', + accessorKey: 'lastUsedAt', + enableSorting: false, + cell: ({ row }) => { + const pat = row.original; + if (!pat.lastUsedAt || isNullTimestamp(pat.lastUsedAt)) return null; + const d = timestampToDayjs(pat.lastUsedAt); + return d ? {d.fromNow()} : null; + } + }, + { + header: '', + accessorKey: 'id', + enableSorting: false, + styles: { + cell: { width: '73px' } + }, + cell: ({ row }) => ( + + ) + } + ]; +} diff --git a/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx b/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx index cf39152e4..d5c25c8a0 100644 --- a/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx +++ b/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import * as yup from 'yup'; import { yupResolver } from '@hookform/resolvers/yup'; @@ -24,6 +24,7 @@ import { Flex, InputField, Label, + Radio, Select, Skeleton, Text, @@ -43,8 +44,7 @@ const baseFields = { projectIds: yup .array() .of(yup.string().required()) - .min(1, 'At least one project is required') - .required('Project is a required field') + .default([]) }; const createPATSchema = yup @@ -88,6 +88,10 @@ export function PATFormDialog({ handleSubmit, reset, getValues, + watch, + setValue, + setError, + clearErrors, formState: { errors, isSubmitting, isDirty } } = useForm({ resolver: yupResolver(isUpdateMode ? updatePATSchema : createPATSchema), @@ -103,6 +107,9 @@ export function PATFormDialog({ // null = unchecked, true = available, false = taken const [titleAvailable, setTitleAvailable] = useState(null); const [titleChecking, setTitleChecking] = useState(false); + const [projectAccess, setProjectAccess] = useState<'all' | 'selective'>( + 'all' + ); const handleOpenChange = (open: boolean) => { if (open && initialData) { @@ -115,6 +122,7 @@ export function PATFormDialog({ const validProjectIds = (projectScope?.resourceIds || []).filter(id => projects.some(p => p.id === id) ); + setProjectAccess(validProjectIds.length > 0 ? 'selective' : 'all'); reset({ title: initialData.title, expiryDays: '', @@ -127,6 +135,7 @@ export function PATFormDialog({ if (!open) { reset(); setTitleAvailable(null); + setProjectAccess('all'); } }; @@ -169,6 +178,19 @@ export function PATFormDialog({ [projectRolesData] ); + const watchedOrgRoleId = watch('orgRoleId'); + const isOrgAdmin = useMemo(() => { + const role = orgRoles.find(r => r.id === watchedOrgRoleId); + return role?.name?.includes('admin') ?? false; + }, [orgRoles, watchedOrgRoleId]); + + useEffect(() => { + if (isOrgAdmin) { + setProjectAccess('all'); + setValue('projectIds', []); + } + }, [isOrgAdmin, setValue]); + const { mutateAsync: createPAT } = useMutation( FrontierServiceQueries.createCurrentUserPAT ); @@ -210,6 +232,17 @@ export function PATFormDialog({ async (data: FormData) => { if (!orgId) return; + if ( + projectAccess === 'selective' && + (!data.projectIds || data.projectIds.length === 0) + ) { + setError('projectIds', { + type: 'manual', + message: 'At least one project is required' + }); + return; + } + const scopes = [ { roleId: data.orgRoleId, @@ -219,7 +252,7 @@ export function PATFormDialog({ { roleId: data.projectRoleId, resourceType: PERMISSIONS.ProjectNamespace, - resourceIds: data.projectIds + resourceIds: projectAccess === 'all' ? [] : data.projectIds } ]; @@ -285,7 +318,9 @@ export function PATFormDialog({ handle, reset, onCreated, - onUpdated + onUpdated, + projectAccess, + setError ] ); @@ -439,37 +474,78 @@ export function PATFormDialog({ )} - - - ( - - )} - /> + + + + All + + + + + + Selective projects + + + + + + {projectAccess === 'selective' && ( + ( + + )} + /> + )} {errors.projectIds && ( {String(errors.projectIds?.message)} diff --git a/web/sdk/react/views-new/pat/pat-view.module.css b/web/sdk/react/views-new/pat/pat-view.module.css index dd9c78b6b..d75d5c2bc 100644 --- a/web/sdk/react/views-new/pat/pat-view.module.css +++ b/web/sdk/react/views-new/pat/pat-view.module.css @@ -1,41 +1,12 @@ -.tokenList { - border: 1px solid var(--rs-color-border-base-primary); - border-radius: var(--rs-radius-2); - width: 100%; +.tableRoot { + border: none; } -.tokenListHeader { - padding: var(--rs-space-5); - border-bottom: 0.5px solid var(--rs-color-border-base-primary); +.revokeButton { + opacity: 0; + transition: opacity 0.15s; } -.tokenCell { - padding: var(--rs-space-5); - border-bottom: 0.5px solid var(--rs-color-border-base-primary); - width: 100%; -} - -.tokenCell:last-child { - border-bottom: none; -} - -.callout { - flex: 1; - width: 100%; - box-sizing: border-box; -} - -.searchInput { - max-width: 360px; -} - -.tokenIcon { - width: 48px; - height: 48px; - border-radius: var(--rs-space-2); - background-color: var(--rs-color-background-neutral-secondary); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; +:global(tr):hover .revokeButton { + opacity: 1; } diff --git a/web/sdk/react/views-new/pat/pat-view.tsx b/web/sdk/react/views-new/pat/pat-view.tsx index 602c6376e..6d703bfbf 100644 --- a/web/sdk/react/views-new/pat/pat-view.tsx +++ b/web/sdk/react/views-new/pat/pat-view.tsx @@ -1,17 +1,15 @@ 'use client'; import { useMemo } from 'react'; -import { LockClosedIcon, MagnifyingGlassIcon } from '@radix-ui/react-icons'; -import { useDebouncedState } from '@raystack/apsara-v1/hooks'; +import { LockClosedIcon } from '@radix-ui/react-icons'; import { AlertDialog, Button, + DataTable, Dialog, EmptyState, Flex, - InputField, - Skeleton, - Text + Skeleton } from '@raystack/apsara-v1'; import { useQuery } from '@connectrpc/connect-query'; import { create } from '@bufbuild/protobuf'; @@ -22,14 +20,12 @@ import { SearchCurrentUserPATsRequestSchema, RQLRequestSchema } from '@raystack/proton/frontier'; -import type { PAT } 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 { timestampToDayjs, isNullTimestamp } from '../../../utils/timestamp'; -import { TokenCell } from './components/token-cell'; +import { getColumns } from './components/pat-columns'; import { PATFormDialog } from './components/pat-form-dialog'; import { PATCreatedDialog } from './components/pat-created-dialog'; import { RevokePATDialog } from './components/revoke-pat-dialog'; @@ -53,15 +49,8 @@ export function PatsView({ onPATClick }: PatsViewProps = {}) { } = useFrontier(); const t = useTerminology(); - const [debouncedSearch, setDebouncedSearch] = useDebouncedState('', 300); - const orgId = organization?.id ?? ''; - const rqlQuery = useMemo( - () => create(RQLRequestSchema, { search: debouncedSearch || '' }), - [debouncedSearch] - ); - const { data: patsData, isLoading: isPatsLoading, @@ -70,7 +59,7 @@ export function PatsView({ onPATClick }: PatsViewProps = {}) { FrontierServiceQueries.searchCurrentUserPATs, create(SearchCurrentUserPATsRequestSchema, { orgId, - query: rqlQuery + query: create(RQLRequestSchema, {}) }), { enabled: Boolean(orgId) @@ -79,21 +68,20 @@ export function PatsView({ onPATClick }: PatsViewProps = {}) { const pats = useMemo(() => patsData?.pats ?? [], [patsData]); - const isLoading = !organization?.id || isActiveOrganizationLoading || isPatsLoading; - const hasNoPats = !isLoading && pats.length === 0 && !debouncedSearch.trim(); + const isInitialLoading = !organization?.id || isActiveOrganizationLoading; + const hasNoPats = !isInitialLoading && !isPatsLoading && pats.length === 0; const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT; - const formatExpiry = (pat: PAT): string => { - const d = timestampToDayjs(pat.expiresAt); - return d ? `Exp: ${d.format(dateFormat)}` : ''; - }; - - const formatLastUsed = (pat: PAT): string => { - if (!pat.lastUsedAt || isNullTimestamp(pat.lastUsedAt)) return ''; - const d = timestampToDayjs(pat.lastUsedAt); - return d ? `Last used ${d.fromNow()}` : ''; - }; + const columns = useMemo( + () => + getColumns({ + dateFormat, + onRevoke: (patId: string) => + revokePATDialogHandle.openWithPayload(patId) + }), + [dateFormat] + ); const handlePATCreated = (token: string) => { patCreatedDialogHandle.openWithPayload(token); @@ -110,7 +98,7 @@ export function PatsView({ onPATClick }: PatsViewProps = {}) { description={`Create a personal access token to enable secure access to ${t.appName()} resources via PAT token`} /> - {isLoading ? ( + {isInitialLoading ? ( @@ -134,63 +122,43 @@ export function PatsView({ onPATClick }: PatsViewProps = {}) { } /> ) : ( - <> - } - onChange={e => setDebouncedSearch(e.target.value)} - className={styles.searchInput} - data-test-id="frontier-sdk-pat-search-input" - /> - -
- - - {pats.length}{' '} - {pats.length === 1 ? 'Token' : 'Tokens'} - + onPATClick?.(row.id)} + > + + + - - {pats.length === 0 ? ( - - - No tokens matching your search - - - ) : ( - pats.map(pat => ( - onPATClick?.(pat.id)} - onRevoke={() => - revokePATDialogHandle.openWithPayload(pat.id) - } + } + heading="No tokens matching your search" /> - )) - )} -
- + } + classNames={{ + root: styles.tableRoot + }} + /> +
+ )} Date: Fri, 24 Apr 2026 10:02:52 +0530 Subject: [PATCH 3/5] feat: update pat design --- web/sdk/admin/index.ts | 4 +- web/sdk/admin/views/audit-logs/index.tsx | 4 +- web/sdk/admin/views/invoices/index.tsx | 4 +- .../organizations/details/apis/index.tsx | 4 +- .../organizations/details/invoices/index.tsx | 4 +- .../organizations/details/members/index.tsx | 4 +- .../organizations/details/projects/index.tsx | 4 +- .../details/projects/members/index.tsx | 4 +- .../organizations/details/tokens/index.tsx | 4 +- .../admin/views/organizations/list/index.tsx | 4 +- web/sdk/admin/views/users/list/list.tsx | 4 +- .../views-new/pat/components/pat-columns.tsx | 2 +- .../pat/components/pat-created-dialog.tsx | 8 +- .../pat/components/pat-form-dialog.tsx | 83 +++++++++------- .../pat/components/regenerate-pat-dialog.tsx | 4 +- .../react/views-new/pat/pat-details-view.tsx | 2 +- web/sdk/react/views-new/pat/pat-view.tsx | 98 +++++++++++++++---- .../{admin => }/utils/connect-pagination.ts | 0 web/sdk/{admin => }/utils/transform-query.ts | 0 19 files changed, 155 insertions(+), 86 deletions(-) rename web/sdk/{admin => }/utils/connect-pagination.ts (100%) rename web/sdk/{admin => }/utils/transform-query.ts (100%) diff --git a/web/sdk/admin/index.ts b/web/sdk/admin/index.ts index 5d9e4cf1b..4127e1270 100644 --- a/web/sdk/admin/index.ts +++ b/web/sdk/admin/index.ts @@ -39,11 +39,11 @@ export { DEFAULT_PAGE_SIZE, getGroupCountMapFromFirstPage, type ConnectRPCPaginatedResponse, -} from "./utils/connect-pagination"; +} from "~/utils/connect-pagination"; export { transformDataTableQueryToRQLRequest, type TransformOptions, -} from "./utils/transform-query"; +} from "~/utils/transform-query"; export { type Config, type AdminTerminologyConfig, diff --git a/web/sdk/admin/views/audit-logs/index.tsx b/web/sdk/admin/views/audit-logs/index.tsx index 79b5fc727..bed2be3a5 100644 --- a/web/sdk/admin/views/audit-logs/index.tsx +++ b/web/sdk/admin/views/audit-logs/index.tsx @@ -22,8 +22,8 @@ import { getConnectNextPageParam, getGroupCountMapFromFirstPage, DEFAULT_PAGE_SIZE, -} from "../../utils/connect-pagination"; -import { transformDataTableQueryToRQLRequest } from "../../utils/transform-query"; +} from "~/utils/connect-pagination"; +import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import SidePanelDetails from "./sidepanel-details"; import { useQueryClient } from "@tanstack/react-query"; diff --git a/web/sdk/admin/views/invoices/index.tsx b/web/sdk/admin/views/invoices/index.tsx index 812b3700c..e6ac9797b 100644 --- a/web/sdk/admin/views/invoices/index.tsx +++ b/web/sdk/admin/views/invoices/index.tsx @@ -16,9 +16,9 @@ import { AdminServiceQueries } from "@raystack/proton/frontier"; import { getConnectNextPageParam, DEFAULT_PAGE_SIZE, -} from "../../utils/connect-pagination"; +} from "~/utils/connect-pagination"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; -import { transformDataTableQueryToRQLRequest } from "../../utils/transform-query"; +import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; import { useTerminology } from "../../hooks/useTerminology"; const NoInvoices = () => { diff --git a/web/sdk/admin/views/organizations/details/apis/index.tsx b/web/sdk/admin/views/organizations/details/apis/index.tsx index 523169e08..55c0ff414 100644 --- a/web/sdk/admin/views/organizations/details/apis/index.tsx +++ b/web/sdk/admin/views/organizations/details/apis/index.tsx @@ -16,8 +16,8 @@ import { getConnectNextPageParam, getGroupCountMapFromFirstPage, DEFAULT_PAGE_SIZE, -} from "../../../../utils/connect-pagination"; -import { transformDataTableQueryToRQLRequest } from "../../../../utils/transform-query"; +} from "~/utils/connect-pagination"; +import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; import { useDebounceValue } from "usehooks-ts"; import { useTerminology } from "../../../../hooks/useTerminology"; diff --git a/web/sdk/admin/views/organizations/details/invoices/index.tsx b/web/sdk/admin/views/organizations/details/invoices/index.tsx index 28ee6a2d1..19fb093ab 100644 --- a/web/sdk/admin/views/organizations/details/invoices/index.tsx +++ b/web/sdk/admin/views/organizations/details/invoices/index.tsx @@ -12,8 +12,8 @@ import { getConnectNextPageParam, DEFAULT_PAGE_SIZE, getGroupCountMapFromFirstPage, -} from "../../../../utils/connect-pagination"; -import { transformDataTableQueryToRQLRequest } from "../../../../utils/transform-query"; +} from "~/utils/connect-pagination"; +import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; import { useDebounceValue } from "usehooks-ts"; import { useTerminology } from "../../../../hooks/useTerminology"; diff --git a/web/sdk/admin/views/organizations/details/members/index.tsx b/web/sdk/admin/views/organizations/details/members/index.tsx index c33179bf0..d92970259 100644 --- a/web/sdk/admin/views/organizations/details/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/members/index.tsx @@ -20,8 +20,8 @@ import { RemoveMember } from './remove-member'; import { getConnectNextPageParam, DEFAULT_PAGE_SIZE -} from '../../../../utils/connect-pagination'; -import { transformDataTableQueryToRQLRequest } from '../../../../utils/transform-query'; +} from '~/utils/connect-pagination'; +import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query'; import { useDebounceValue } from 'usehooks-ts'; const DEFAULT_SORT: DataTableSort = { name: 'orgJoinedAt', order: 'desc' }; diff --git a/web/sdk/admin/views/organizations/details/projects/index.tsx b/web/sdk/admin/views/organizations/details/projects/index.tsx index eb26a0443..7f24dc73d 100644 --- a/web/sdk/admin/views/organizations/details/projects/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/index.tsx @@ -18,8 +18,8 @@ import { ProjectMembersDialog } from "./members"; import { getConnectNextPageParam, DEFAULT_PAGE_SIZE -} from '../../../../utils/connect-pagination'; -import { transformDataTableQueryToRQLRequest } from '../../../../utils/transform-query'; +} from '~/utils/connect-pagination'; +import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query'; import { useDebounceValue } from 'usehooks-ts'; import { useTerminology } from "../../../../hooks/useTerminology"; diff --git a/web/sdk/admin/views/organizations/details/projects/members/index.tsx b/web/sdk/admin/views/organizations/details/projects/members/index.tsx index c2ed1f009..fdf2fad6c 100644 --- a/web/sdk/admin/views/organizations/details/projects/members/index.tsx +++ b/web/sdk/admin/views/organizations/details/projects/members/index.tsx @@ -20,8 +20,8 @@ import { AssignRole } from "./assign-role"; import { PROJECT_NAMESPACE } from "../../types"; import { RemoveMember } from "./remove-member"; import { AddMembersDropdown } from "./add-members-dropdown"; -import { getConnectNextPageParam, DEFAULT_PAGE_SIZE } from "../../../../../utils/connect-pagination"; -import { transformDataTableQueryToRQLRequest } from "../../../../../utils/transform-query"; +import { getConnectNextPageParam, DEFAULT_PAGE_SIZE } from "~/utils/connect-pagination"; +import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; const NoMembers = () => { return ( diff --git a/web/sdk/admin/views/organizations/details/tokens/index.tsx b/web/sdk/admin/views/organizations/details/tokens/index.tsx index da2d523ec..875049dd3 100644 --- a/web/sdk/admin/views/organizations/details/tokens/index.tsx +++ b/web/sdk/admin/views/organizations/details/tokens/index.tsx @@ -8,8 +8,8 @@ import { OrganizationContext } from "../contexts/organization-context"; import { PageTitle } from "../../../../components/PageTitle"; import { AdminServiceQueries } from "@raystack/proton/frontier"; import { useInfiniteQuery } from "@connectrpc/connect-query"; -import { getConnectNextPageParam, DEFAULT_PAGE_SIZE } from "../../../../utils/connect-pagination"; -import { transformDataTableQueryToRQLRequest } from "../../../../utils/transform-query"; +import { getConnectNextPageParam, DEFAULT_PAGE_SIZE } from "~/utils/connect-pagination"; +import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; import { getColumns } from "./columns"; import { useDebounceValue } from "usehooks-ts"; import { useTerminology } from "../../../../hooks/useTerminology"; diff --git a/web/sdk/admin/views/organizations/list/index.tsx b/web/sdk/admin/views/organizations/list/index.tsx index c737076c9..ea42216a9 100644 --- a/web/sdk/admin/views/organizations/list/index.tsx +++ b/web/sdk/admin/views/organizations/list/index.tsx @@ -20,8 +20,8 @@ import { getConnectNextPageParam, getGroupCountMapFromFirstPage, DEFAULT_PAGE_SIZE, -} from "../../../utils/connect-pagination"; -import { transformDataTableQueryToRQLRequest } from "../../../utils/transform-query"; +} from "~/utils/connect-pagination"; +import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { useDebouncedState } from "@raystack/apsara/hooks"; import { useTerminology } from "../../../hooks/useTerminology"; diff --git a/web/sdk/admin/views/users/list/list.tsx b/web/sdk/admin/views/users/list/list.tsx index b9fd2d392..7c9fd2d6b 100644 --- a/web/sdk/admin/views/users/list/list.tsx +++ b/web/sdk/admin/views/users/list/list.tsx @@ -11,8 +11,8 @@ import { getConnectNextPageParam, getGroupCountMapFromFirstPage, DEFAULT_PAGE_SIZE, -} from "../../../utils/connect-pagination"; -import { transformDataTableQueryToRQLRequest } from "../../../utils/transform-query"; +} from "~/utils/connect-pagination"; +import { transformDataTableQueryToRQLRequest } from "~/utils/transform-query"; import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; import { useDebouncedState } from "@raystack/apsara/hooks"; import { useTerminology } from "../../../hooks/useTerminology"; diff --git a/web/sdk/react/views-new/pat/components/pat-columns.tsx b/web/sdk/react/views-new/pat/components/pat-columns.tsx index 3c6d73394..cd18f1d5d 100644 --- a/web/sdk/react/views-new/pat/components/pat-columns.tsx +++ b/web/sdk/react/views-new/pat/components/pat-columns.tsx @@ -2,7 +2,7 @@ import { Button, Text, DataTableColumnDef } from '@raystack/apsara-v1'; import type { PAT } from '@raystack/proton/frontier'; -import { timestampToDayjs, isNullTimestamp } from '../../../../utils/timestamp'; +import { timestampToDayjs, isNullTimestamp } from '~/utils/timestamp'; import styles from '../pat-view.module.css'; export function getColumns({ diff --git a/web/sdk/react/views-new/pat/components/pat-created-dialog.tsx b/web/sdk/react/views-new/pat/components/pat-created-dialog.tsx index 21f04da67..baa53d03a 100644 --- a/web/sdk/react/views-new/pat/components/pat-created-dialog.tsx +++ b/web/sdk/react/views-new/pat/components/pat-created-dialog.tsx @@ -32,8 +32,8 @@ export function PATCreatedDialog({ handle, onClose }: PATCreatedDialogProps) { - You've successfully added a new personal access token. Copy - the token now + Successfully added a new personal access token. Please copy the + token. } className={styles.callout}> - Warning : Make sure you copy the above token now. We don't - store it and you will not be able to see it again. + Warning : Make sure you copy the above token now. This token + will only be shown once. Store it securely. diff --git a/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx b/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx index d5c25c8a0..354c59699 100644 --- a/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx +++ b/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx @@ -27,15 +27,22 @@ import { Radio, Select, Skeleton, + Spinner, Text, toastManager } from '@raystack/apsara-v1'; -import { useFrontier } from '../../../contexts/FrontierContext'; -import { DEFAULT_DATE_FORMAT } from '../../../utils/constants'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { DEFAULT_DATE_FORMAT } from '~/react/utils/constants'; import { PERMISSIONS } from '../../../../utils'; import { handleConnectError } from '~/utils/error'; -const EXPIRY_OPTIONS = [15, 30, 60, 90] as const; +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; const baseFields = { title: yup.string().required('Name is required'), @@ -50,14 +57,14 @@ const baseFields = { const createPATSchema = yup .object({ ...baseFields, - expiryDays: yup.string().required('Expiry date is required') + expiry: yup.string().required('Expiry date is required') }) .required(); const updatePATSchema = yup .object({ ...baseFields, - expiryDays: yup.string().default('') + expiry: yup.string().default('') }) .required(); @@ -97,7 +104,7 @@ export function PATFormDialog({ resolver: yupResolver(isUpdateMode ? updatePATSchema : createPATSchema), defaultValues: { title: '', - expiryDays: '', + expiry: '', orgRoleId: '', projectRoleId: '', projectIds: [] @@ -125,7 +132,7 @@ export function PATFormDialog({ setProjectAccess(validProjectIds.length > 0 ? 'selective' : 'all'); reset({ title: initialData.title, - expiryDays: '', + expiry: '', orgRoleId: orgScope?.roleId || '', projectRoleId: projectScope?.roleId || '', projectIds: validProjectIds @@ -154,34 +161,31 @@ export function PATFormDialog({ return orderBy(list, ['title'], ['asc']); }, [projectsData]); - const { data: orgRolesData, isLoading: isOrgRolesLoading } = useQuery( + const { data: rolesData, isLoading: isRolesLoading } = useQuery( FrontierServiceQueries.listRolesForPAT, - create(ListRolesForPATRequestSchema, { - scopes: [PERMISSIONS.OrganizationNamespace] - }), + create(ListRolesForPATRequestSchema, { scopes: [] }), { 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 { orgRoles, projectRoles } = useMemo(() => { + const roles = rolesData?.roles ?? []; + return { + orgRoles: roles.filter(r => + r.scopes?.includes(PERMISSIONS.OrganizationNamespace) + ), + projectRoles: roles.filter(r => + r.scopes?.includes(PERMISSIONS.ProjectNamespace) + ) + }; + }, [rolesData]); const watchedOrgRoleId = watch('orgRoleId'); const isOrgAdmin = useMemo(() => { const role = orgRoles.find(r => r.id === watchedOrgRoleId); - return role?.name?.includes('admin') ?? false; + if (!role) return false; + const orgNs = PERMISSIONS.OrganizationNamespace.replace('/', '_'); + const updatePerm = `${orgNs}_${PERMISSIONS.UpdatePermission}`; + return role.permissions?.some(p => p === updatePerm) ?? false; }, [orgRoles, watchedOrgRoleId]); useEffect(() => { @@ -273,8 +277,10 @@ export function PATFormDialog({ reset(); onUpdated?.(); } else { + const option = EXPIRY_OPTIONS.find(o => o.value === data.expiry); + if (!option) return; const expiresAt = timestampFromDate( - dayjs().add(Number(data.expiryDays), 'day').toDate() + dayjs().add(option.amount, option.unit).toDate() ); const response = await createPAT( create(CreateCurrentUserPATRequestSchema, { @@ -324,8 +330,7 @@ export function PATFormDialog({ ] ); - const isDataLoading = - isProjectsLoading || isOrgRolesLoading || isProjectRolesLoading; + const isDataLoading = isProjectsLoading || isRolesLoading; return ( @@ -369,6 +374,7 @@ export function PATFormDialog({ }} size="large" placeholder="Enter token name" + trailingIcon={titleChecking ? : undefined} error={ errors.title ? String(errors.title?.message) @@ -382,7 +388,7 @@ export function PATFormDialog({ ( )} /> - {errors.expiryDays && ( + {errors.expiry && ( - {String(errors.expiryDays?.message)} + {String(errors.expiry?.message)} )} 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 index 66823207f..e6173143e 100644 --- a/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx +++ b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx @@ -18,8 +18,8 @@ import { Text, toastManager } from '@raystack/apsara-v1'; -import { useFrontier } from '../../../contexts/FrontierContext'; -import { DEFAULT_DATE_FORMAT } from '../../../utils/constants'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { DEFAULT_DATE_FORMAT } from '~/react/utils/constants'; import { handleConnectError } from '~/utils/error'; const EXPIRY_OPTIONS = [15, 30, 60, 90] as const; diff --git a/web/sdk/react/views-new/pat/pat-details-view.tsx b/web/sdk/react/views-new/pat/pat-details-view.tsx index eb91cd248..348c650c1 100644 --- a/web/sdk/react/views-new/pat/pat-details-view.tsx +++ b/web/sdk/react/views-new/pat/pat-details-view.tsx @@ -28,7 +28,7 @@ import { ViewContainer } from '../../components/view-container'; import { ViewHeader } from '../../components/view-header'; import { DEFAULT_DATE_FORMAT } from '../../utils/constants'; import { PERMISSIONS } from '../../../utils'; -import { timestampToDayjs } from '../../../utils/timestamp'; +import { timestampToDayjs } from '~/utils/timestamp'; import { PATCreatedDialog } from './components/pat-created-dialog'; import { PATFormDialog } from './components/pat-form-dialog'; import { diff --git a/web/sdk/react/views-new/pat/pat-view.tsx b/web/sdk/react/views-new/pat/pat-view.tsx index 6d703bfbf..27533dec9 100644 --- a/web/sdk/react/views-new/pat/pat-view.tsx +++ b/web/sdk/react/views-new/pat/pat-view.tsx @@ -11,20 +11,22 @@ import { Flex, Skeleton } from '@raystack/apsara-v1'; -import { useQuery } from '@connectrpc/connect-query'; -import { create } from '@bufbuild/protobuf'; +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, - SearchCurrentUserPATsRequestSchema, - RQLRequestSchema -} from '@raystack/proton/frontier'; +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 } from './components/pat-created-dialog'; @@ -37,6 +39,20 @@ 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', + lastUsedAt: 'last_used_at' + } +}; + export interface PatsViewProps { onPATClick?: (patId: string) => void; } @@ -51,25 +67,51 @@ export function PatsView({ onPATClick }: PatsViewProps = {}) { const orgId = organization?.id ?? ''; + const [tableQuery, setTableQuery] = useDebouncedState( + INITIAL_QUERY, + 200 + ); + + const query = useMemo( + () => transformDataTableQueryToRQLRequest(tableQuery, TRANSFORM_OPTIONS), + [tableQuery] + ); + const { - data: patsData, + data: infiniteData, isLoading: isPatsLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, refetch - } = useQuery( + } = useInfiniteQuery( FrontierServiceQueries.searchCurrentUserPATs, - create(SearchCurrentUserPATsRequestSchema, { - orgId, - query: create(RQLRequestSchema, {}) - }), + { orgId, query }, { - enabled: Boolean(orgId) + enabled: Boolean(orgId), + pageParamKey: 'query', + getNextPageParam: lastPage => + getConnectNextPageParam(lastPage, { query }, 'pats'), + staleTime: 0, + refetchOnWindowFocus: false } ); - const pats = useMemo(() => patsData?.pats ?? [], [patsData]); + const pats = useMemo( + () => infiniteData?.pages?.flatMap(page => page?.pats ?? []) ?? [], + [infiniteData] + ); + const hasActiveQuery = Boolean( + tableQuery.search || tableQuery.filters?.length + ); const isInitialLoading = !organization?.id || isActiveOrganizationLoading; - const hasNoPats = !isInitialLoading && !isPatsLoading && pats.length === 0; + const isTableLoading = isPatsLoading || isFetchingNextPage; + const hasNoPats = + !isInitialLoading && + !isPatsLoading && + !hasActiveQuery && + pats.length === 0; const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT; @@ -83,6 +125,20 @@ export function PatsView({ onPATClick }: PatsViewProps = {}) { [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); }; @@ -125,9 +181,12 @@ export function PatsView({ onPATClick }: PatsViewProps = {}) { onPATClick?.(row.id)} > @@ -136,6 +195,7 @@ export function PatsView({ onPATClick }: PatsViewProps = {}) { placeholder="Search by name." size="large" width={360} + disabled={false} /> - - - - )} + {({ payload }) => { + const token = payload?.token ?? ''; + const isRegenerated = payload?.isRegenerated ?? false; + const description = isRegenerated + ? 'Your personal access token has been regenerated successfully. Please copy and store it securely.' + : 'Successfully added a new personal access token. Please copy the token.'; + return ( + + + Success + + + + {description} + + ) : undefined + } + data-test-id="frontier-sdk-pat-token-input" + /> + } width="100%"> + Warning : Make sure you copy the above token now. This token + will only be shown once. Store it securely. + + + + + + + + + + ); + }} ); } diff --git a/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx b/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx index 354c59699..e08a345d5 100644 --- a/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx +++ b/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx @@ -20,6 +20,7 @@ import { import type { PAT } from '@raystack/proton/frontier'; import { Button, + Chip, Dialog, Flex, InputField, @@ -35,14 +36,10 @@ import { useFrontier } from '~/react/contexts/FrontierContext'; import { DEFAULT_DATE_FORMAT } from '~/react/utils/constants'; import { PERMISSIONS } from '../../../../utils'; import { handleConnectError } from '~/utils/error'; +import { EXPIRY_OPTIONS } from '../utils'; +import styles from '../pat-view.module.css'; -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; +const MAX_VISIBLE_PROJECT_CHIPS = 2; const baseFields = { title: yup.string().required('Name is required'), @@ -217,14 +214,20 @@ export function PATFormDialog({ return; } + const checkedTitle = title; setTitleChecking(true); try { const result = await checkTitle( create(CheckCurrentUserPATTitleRequestSchema, { orgId, title }) ); - setTitleAvailable(result?.available); + if (getValues('title') === checkedTitle) { + setTitleAvailable(result?.available ?? true); + } } catch { - // Ignore check failure — don't block the user + // On check failure, don't block the user + if (getValues('title') === checkedTitle) { + setTitleAvailable(true); + } } finally { setTitleChecking(false); } @@ -382,6 +385,7 @@ export function PATFormDialog({ ? 'This name is already taken' : undefined } + data-test-id="frontier-sdk-pat-form-title-input" /> {!isUpdateMode && ( @@ -536,15 +540,44 @@ export function PATFormDialog({ > - {field.value.length > 0 - ? `${field.value.length} project${field.value.length > 1 ? 's' : ''} selected` - : undefined} + {() => { + const selectedIds = field.value; + const visible = selectedIds.slice( + 0, + MAX_VISIBLE_PROJECT_CHIPS + ); + const remaining = + selectedIds.length - visible.length; + return ( + + {visible.map(id => ( + + + {projects.find(p => p.id === id) + ?.title || id} + + + ))} + {remaining > 0 && ( + {`+${remaining}`} + )} + + ); + } + } {projects.map(project => ( {project.title} @@ -573,6 +606,7 @@ export function PATFormDialog({ size="normal" type="button" onClick={() => handle.close()} + data-test-id="frontier-sdk-pat-form-cancel-btn" > Cancel 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 index e6173143e..18c0aaf4b 100644 --- a/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx +++ b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx @@ -13,7 +13,6 @@ import { Button, Dialog, Flex, - Label, Select, Text, toastManager @@ -21,12 +20,12 @@ import { import { useFrontier } from '~/react/contexts/FrontierContext'; import { DEFAULT_DATE_FORMAT } from '~/react/utils/constants'; import { handleConnectError } from '~/utils/error'; - -const EXPIRY_OPTIONS = [15, 30, 60, 90] as const; +import { EXPIRY_OPTIONS } from '../utils'; +import styles from './regenerate-pat-dialog.module.css'; export interface RegeneratePayload { patId: string; - currentExpiryDays: string; + currentExpiryValue: string; } export interface RegeneratePATDialogProps { @@ -41,7 +40,7 @@ export function RegeneratePATDialog({ const { config } = useFrontier(); const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT; - const [expiryDays, setExpiryDays] = useState(''); + const [expiryValue, setExpiryValue] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const { mutateAsync: regeneratePAT } = useMutation( @@ -50,85 +49,85 @@ export function RegeneratePATDialog({ const handleOpenChange = (open: boolean) => { if (!open) { - setExpiryDays(''); + setExpiryValue(''); } }; - const handleRegenerate = useCallback(async () => { - const days = expiryDays || handle.payload?.currentExpiryDays; - if (!days) return; - - setIsSubmitting(true); - try { - const expiresAt = timestampFromDate( - dayjs().add(Number(days), 'day').toDate() - ); - - const patId = handle.payload?.patId; - if (!patId) return; + const handleRegenerate = useCallback( + async (patId: string, selectedValue: string) => { + const option = EXPIRY_OPTIONS.find(o => o.value === selectedValue); + if (!option) return; - const response = await regeneratePAT( - create(RegenerateCurrentUserPATRequestSchema, { - id: patId, - expiresAt - }) - ); + setIsSubmitting(true); + try { + const expiresAt = timestampFromDate( + dayjs().add(option.amount, option.unit).toDate() + ); - const token = response.pat?.token; - toastManager.add({ - title: 'Token regenerated', - type: 'success' - }); - handle.close(); - setExpiryDays(''); - if (token) onRegenerated?.(token); - } catch (error) { - handleConnectError(error, { - Default: err => - toastManager.add({ - title: 'Something went wrong', - description: err.message, - type: 'error' + const response = await regeneratePAT( + create(RegenerateCurrentUserPATRequestSchema, { + id: patId, + expiresAt }) - }); - } finally { - setIsSubmitting(false); - } - }, [expiryDays, handle, regeneratePAT, onRegenerated]); + ); + + 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 ( {({ payload }) => { - const selectedDays = expiryDays || payload?.currentExpiryDays || ''; + const selectedValue = + expiryValue || payload?.currentExpiryValue || ''; + const patId = payload?.patId; return ( Regenerate Expiry date - - - - Select a new expiry duration for this personal access token. - The current token will be invalidated and a new one will be - generated. - - - - - - + + + Select a new expiry duration for this personal access token. + The current token will be invalidated and replaced with a new + one. + + @@ -136,9 +135,11 @@ export function RegeneratePATDialog({ variant="solid" color="accent" size="normal" - onClick={handleRegenerate} + onClick={() => + patId && handleRegenerate(patId, selectedValue) + } loading={isSubmitting} - disabled={!selectedDays || isSubmitting} + disabled={!selectedValue || isSubmitting} loaderText="Regenerating..." data-test-id="frontier-sdk-pat-regenerate-submit-btn" > 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 index 3a1922943..6cccf0e96 100644 --- a/web/sdk/react/views-new/pat/components/revoke-pat-dialog.tsx +++ b/web/sdk/react/views-new/pat/components/revoke-pat-dialog.tsx @@ -7,13 +7,8 @@ import { FrontierServiceQueries, DeleteCurrentUserPATRequestSchema } from '@raystack/proton/frontier'; -import { - AlertDialog, - Button, - Flex, - Text, - toastManager -} from '@raystack/apsara-v1'; +import { AlertDialog, Button, toastManager } from '@raystack/apsara-v1'; +import styles from './revoke-pat-dialog.module.css'; import { handleConnectError } from '~/utils/error'; export interface RevokePATDialogProps { @@ -54,39 +49,36 @@ export function RevokePATDialog({ handle, onRevoked }: RevokePATDialogProps) { return ( {({ payload: patId }) => ( - - + + Revoke - - - - This is an irreversible action, doing this might lead to permanent - deletion of the data. Do you wish to proceed? - + + 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/pat-details-view.module.css b/web/sdk/react/views-new/pat/pat-details-view.module.css index db8225bb5..3d60abac3 100644 --- a/web/sdk/react/views-new/pat/pat-details-view.module.css +++ b/web/sdk/react/views-new/pat/pat-details-view.module.css @@ -1,6 +1,6 @@ .section { border: 1px solid var(--rs-color-border-base-primary); - border-radius: 4px; + border-radius: var(--rs-radius-2); width: 100%; } @@ -14,22 +14,26 @@ .detailRow { display: flex; - gap: 8px; + gap: var(--rs-space-3); align-items: flex-start; } .detailLabel { flex-shrink: 0; - min-width: 120px; + min-width: var(--rs-space-17); } .chipGroup { display: flex; flex-wrap: wrap; - gap: 4px; + gap: var(--rs-radius-2); align-items: center; } .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 index 348c650c1..8cb451a99 100644 --- a/web/sdk/react/views-new/pat/pat-details-view.tsx +++ b/web/sdk/react/views-new/pat/pat-details-view.tsx @@ -29,18 +29,22 @@ import { ViewHeader } from '../../components/view-header'; import { DEFAULT_DATE_FORMAT } from '../../utils/constants'; import { PERMISSIONS } from '../../../utils'; import { timestampToDayjs } from '~/utils/timestamp'; -import { PATCreatedDialog } from './components/pat-created-dialog'; +import { + PATCreatedDialog, + type PATCreatedPayload +} from './components/pat-created-dialog'; import { PATFormDialog } from './components/pat-form-dialog'; import { RegeneratePATDialog, type RegeneratePayload } from './components/regenerate-pat-dialog'; import { RevokePATDialog } from './components/revoke-pat-dialog'; +import { getExpiryOptionValue } from './utils'; import styles from './pat-details-view.module.css'; const updatePATDialogHandle = Dialog.createHandle(); const regenerateDialogHandle = Dialog.createHandle(); -const patCreatedDialogHandle = Dialog.createHandle(); +const patCreatedDialogHandle = Dialog.createHandle(); const revokePATDialogHandle = AlertDialog.createHandle(); export interface PATDetailsViewProps { @@ -155,20 +159,21 @@ export function PATDetailsView({ return d ? d.format(dateFormat) : ''; }, [pat, dateFormat]); - const { expiryInfo, expiryDays } = useMemo(() => { + const { expiryInfo, currentExpiryValue } = useMemo(() => { const created = timestampToDayjs(pat?.createdAt); const expires = timestampToDayjs(pat?.expiresAt); - if (!created || !expires) return { expiryInfo: '', expiryDays: '' }; + if (!created || !expires) + return { expiryInfo: '', currentExpiryValue: '' }; const days = expires.diff(created, 'day'); return { expiryInfo: `${days} Days (Exp: ${expires.format(dateFormat)})`, - expiryDays: String(days) + currentExpiryValue: getExpiryOptionValue(created, expires) }; }, [pat, dateFormat]); const handleRegenerated = useCallback( (token: string) => { - patCreatedDialogHandle.openWithPayload(token); + patCreatedDialogHandle.openWithPayload({ token, isRegenerated: true }); }, [] ); @@ -312,7 +317,7 @@ export function PATDetailsView({ onClick={() => regenerateDialogHandle.openWithPayload({ patId, - currentExpiryDays: expiryDays + currentExpiryValue }) } data-test-id="frontier-sdk-pat-regenerate-btn" diff --git a/web/sdk/react/views-new/pat/pat-view.module.css b/web/sdk/react/views-new/pat/pat-view.module.css index d75d5c2bc..0420a1152 100644 --- a/web/sdk/react/views-new/pat/pat-view.module.css +++ b/web/sdk/react/views-new/pat/pat-view.module.css @@ -10,3 +10,15 @@ :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 index 27533dec9..cb01cc031 100644 --- a/web/sdk/react/views-new/pat/pat-view.tsx +++ b/web/sdk/react/views-new/pat/pat-view.tsx @@ -29,14 +29,17 @@ import { import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query'; import { getColumns } from './components/pat-columns'; import { PATFormDialog } from './components/pat-form-dialog'; -import { PATCreatedDialog } from './components/pat-created-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 patCreatedDialogHandle = Dialog.createHandle(); const revokePATDialogHandle = AlertDialog.createHandle(); const DEFAULT_SORT: DataTableSort = { name: 'title', order: 'asc' }; @@ -140,7 +143,7 @@ export function PatsView({ onPATClick }: PatsViewProps = {}) { }; const handlePATCreated = (token: string) => { - patCreatedDialogHandle.openWithPayload(token); + patCreatedDialogHandle.openWithPayload({ token }); }; const handleSuccessDialogClose = () => { 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..eab0fe14a --- /dev/null +++ b/web/sdk/react/views-new/pat/utils.ts @@ -0,0 +1,22 @@ +import dayjs from 'dayjs'; + +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]; + +export function getExpiryOptionValue( + createdAt?: dayjs.Dayjs | null, + expiresAt?: dayjs.Dayjs | null +): string { + if (!createdAt || !expiresAt) return ''; + const match = EXPIRY_OPTIONS.find(option => + createdAt.add(option.amount, option.unit).isSame(expiresAt, 'day') + ); + return match?.value ?? ''; +} From d8aea490ebe2b4a705c631e385d5ae2b7c082700 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 27 Apr 2026 00:11:41 +0530 Subject: [PATCH 5/5] feat: update pat design --- .../views-new/pat/components/pat-columns.tsx | 16 +- .../components/pat-project-chips.module.css | 14 ++ .../pat/components/pat-project-chips.tsx | 80 ++++++++ .../views-new/pat/pat-details-view.module.css | 22 +-- .../react/views-new/pat/pat-details-view.tsx | 185 ++++++++++-------- web/sdk/react/views-new/pat/pat-view.tsx | 2 +- web/sdk/react/views-new/pat/utils.ts | 21 +- 7 files changed, 233 insertions(+), 107 deletions(-) create mode 100644 web/sdk/react/views-new/pat/components/pat-project-chips.module.css create mode 100644 web/sdk/react/views-new/pat/components/pat-project-chips.tsx diff --git a/web/sdk/react/views-new/pat/components/pat-columns.tsx b/web/sdk/react/views-new/pat/components/pat-columns.tsx index cd18f1d5d..0c2af867b 100644 --- a/web/sdk/react/views-new/pat/components/pat-columns.tsx +++ b/web/sdk/react/views-new/pat/components/pat-columns.tsx @@ -1,10 +1,14 @@ 'use client'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; import { Button, Text, DataTableColumnDef } from '@raystack/apsara-v1'; import type { PAT } from '@raystack/proton/frontier'; import { timestampToDayjs, isNullTimestamp } from '~/utils/timestamp'; import styles from '../pat-view.module.css'; +dayjs.extend(relativeTime); + export function getColumns({ dateFormat, onRevoke @@ -25,19 +29,19 @@ export function getColumns({ accessorKey: 'expiresAt', enableSorting: false, cell: ({ row }) => { - const d = timestampToDayjs(row.original.expiresAt); - return d ? {d.format(dateFormat)} : null; + const date = timestampToDayjs(row.original.expiresAt); + return date ? {date.format(dateFormat)} : null; } }, { header: 'Last used', - accessorKey: 'lastUsedAt', + accessorKey: 'usedAt', enableSorting: false, cell: ({ row }) => { const pat = row.original; - if (!pat.lastUsedAt || isNullTimestamp(pat.lastUsedAt)) return null; - const d = timestampToDayjs(pat.lastUsedAt); - return d ? {d.fromNow()} : null; + if (!pat.usedAt || isNullTimestamp(pat.usedAt)) return null; + const date = timestampToDayjs(pat.usedAt); + return date ? {date.fromNow()} : null; } }, { diff --git a/web/sdk/react/views-new/pat/components/pat-project-chips.module.css b/web/sdk/react/views-new/pat/components/pat-project-chips.module.css new file mode 100644 index 000000000..b64fd70d7 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/pat-project-chips.module.css @@ -0,0 +1,14 @@ +.compact { + display: flex; + gap: var(--rs-space-2); +} + +.expanded { + display: flex; + flex-wrap: wrap; + gap: var(--rs-space-2); +} + +.countChip { + cursor: pointer; +} diff --git a/web/sdk/react/views-new/pat/components/pat-project-chips.tsx b/web/sdk/react/views-new/pat/components/pat-project-chips.tsx new file mode 100644 index 000000000..52435d9cf --- /dev/null +++ b/web/sdk/react/views-new/pat/components/pat-project-chips.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useLayoutEffect, useRef, useState } from 'react'; +import { Chip } from '@raystack/apsara-v1'; +import styles from './pat-project-chips.module.css'; + +interface ProjectChipItem { + id: string; + title: string; +} + +interface PATProjectChipsProps { + projects: ProjectChipItem[]; +} + +const MAX_WIDTH = 500; +const COUNT_CHIP_WIDTH_RESERVE = 56; +const CHIP_GAP = 4; + +export function PATProjectChips({ projects }: PATProjectChipsProps) { + const [expanded, setExpanded] = useState(false); + const [visibleCount, setVisibleCount] = useState(null); + const containerRef = useRef(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/pat-details-view.module.css b/web/sdk/react/views-new/pat/pat-details-view.module.css index 3d60abac3..425d3d19b 100644 --- a/web/sdk/react/views-new/pat/pat-details-view.module.css +++ b/web/sdk/react/views-new/pat/pat-details-view.module.css @@ -2,34 +2,18 @@ border: 1px solid var(--rs-color-border-base-primary); border-radius: var(--rs-radius-2); width: 100%; -} - -.sectionHeader { + display: flex; + flex-direction: column; + gap: var(--rs-space-6); padding: var(--rs-space-5); } -.sectionBody { - padding: 0 var(--rs-space-5) var(--rs-space-5); -} - .detailRow { display: flex; gap: var(--rs-space-3); align-items: flex-start; } -.detailLabel { - flex-shrink: 0; - min-width: var(--rs-space-17); -} - -.chipGroup { - display: flex; - flex-wrap: wrap; - gap: var(--rs-radius-2); - align-items: center; -} - .menuContent { min-width: 160px; } diff --git a/web/sdk/react/views-new/pat/pat-details-view.tsx b/web/sdk/react/views-new/pat/pat-details-view.tsx index 8cb451a99..a3b812beb 100644 --- a/web/sdk/react/views-new/pat/pat-details-view.tsx +++ b/web/sdk/react/views-new/pat/pat-details-view.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useCallback, useEffect, useMemo } from 'react'; +import { ReactNode, useCallback, useEffect, useMemo } from 'react'; import { DotsHorizontalIcon } from '@radix-ui/react-icons'; import { AlertDialog, Breadcrumb, Button, - Chip, Dialog, Flex, IconButton, @@ -23,30 +22,55 @@ import { 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 { timestampToDayjs } from '~/utils/timestamp'; +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 } from './utils'; +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; @@ -149,25 +173,40 @@ export function PATDetailsView({ const scopeProjects = useMemo(() => { if (!projectScope?.resourceIds?.length) return []; - return projects.filter(p => - projectScope.resourceIds.includes(p.id || '') - ); + 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 created = timestampToDayjs(pat?.createdAt); + const reference = getExpiryReferenceDayjs(pat); const expires = timestampToDayjs(pat?.expiresAt); - if (!created || !expires) + if (!reference || !expires) return { expiryInfo: '', currentExpiryValue: '' }; - const days = expires.diff(created, 'day'); + const days = expires.diff(reference, 'day'); return { - expiryInfo: `${days} Days (Exp: ${expires.format(dateFormat)})`, - currentExpiryValue: getExpiryOptionValue(created, expires) + expiryInfo: `${expires.format(dateFormat)} (${days} Days)`, + currentExpiryValue: getExpiryOptionValue(reference, expires) }; }, [pat, dateFormat]); @@ -223,6 +262,23 @@ export function PATDetailsView({ + updatePATDialogHandle.open(null)} + data-test-id="frontier-sdk-pat-update-menu-btn" + > + Update + + + regenerateDialogHandle.openWithPayload({ + patId, + currentExpiryValue + }) + } + data-test-id="frontier-sdk-pat-regenerate-menu-btn" + > + Regenerate + revokePATDialogHandle.openWithPayload(patId)} style={{ color: 'var(--rs-color-foreground-danger-primary)' }} @@ -236,17 +292,13 @@ export function PATDetailsView({ {isLoading ? ( + - ) : ( <>
- + General @@ -260,75 +312,54 @@ export function PATDetailsView({ Update - + + {createdOn && {createdOn}} + {lastUsed && {lastUsed}} {orgRoleName && ( -
- - Organization role : - - - {orgRoleName} - -
+ {orgRoleName} )} {projectRoleName && ( -
- - Project role: - - - {projectRoleName} - -
+ {projectRoleName} )} - {scopeProjects.length > 0 && ( -
- - Projects + + {isAllProjects || scopeProjects.length === 0 ? ( + + All projects -
- {scopeProjects.map(project => ( - - {project.title} - - ))} -
-
- )} + ) : ( + + )} +
- - - - Expiry Date - - - - {createdOn && ( - Created on: {createdOn} + + + Expiry Details + + + + + {expiryInfo && ( + {expiryInfo} + )} + {regeneratedOn && ( + {regeneratedOn} )} - {expiryInfo && {expiryInfo}}
diff --git a/web/sdk/react/views-new/pat/pat-view.tsx b/web/sdk/react/views-new/pat/pat-view.tsx index cb01cc031..6a4fb2460 100644 --- a/web/sdk/react/views-new/pat/pat-view.tsx +++ b/web/sdk/react/views-new/pat/pat-view.tsx @@ -52,7 +52,7 @@ const TRANSFORM_OPTIONS = { createdAt: 'created_at', updatedAt: 'updated_at', expiresAt: 'expires_at', - lastUsedAt: 'last_used_at' + usedAt: 'used_at' } }; diff --git a/web/sdk/react/views-new/pat/utils.ts b/web/sdk/react/views-new/pat/utils.ts index eab0fe14a..1824b050e 100644 --- a/web/sdk/react/views-new/pat/utils.ts +++ b/web/sdk/react/views-new/pat/utils.ts @@ -1,4 +1,6 @@ -import dayjs from 'dayjs'; +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 }, @@ -10,13 +12,24 @@ export const EXPIRY_OPTIONS = [ 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( - createdAt?: dayjs.Dayjs | null, + reference?: dayjs.Dayjs | null, expiresAt?: dayjs.Dayjs | null ): string { - if (!createdAt || !expiresAt) return ''; + if (!reference || !expiresAt) return ''; const match = EXPIRY_OPTIONS.find(option => - createdAt.add(option.amount, option.unit).isSame(expiresAt, 'day') + reference.add(option.amount, option.unit).isSame(expiresAt, 'day') ); return match?.value ?? ''; }