diff --git a/web/apps/client-demo/src/Router.tsx b/web/apps/client-demo/src/Router.tsx index 0b34631a3..97b007d6b 100644 --- a/web/apps/client-demo/src/Router.tsx +++ b/web/apps/client-demo/src/Router.tsx @@ -22,6 +22,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'; import Plans from './pages/settings/Plans'; function Router() { @@ -51,6 +53,8 @@ function Router() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/web/apps/client-demo/src/pages/Settings.tsx b/web/apps/client-demo/src/pages/Settings.tsx index 4b5776cb1..fcd910111 100644 --- a/web/apps/client-demo/src/pages/Settings.tsx +++ b/web/apps/client-demo/src/pages/Settings.tsx @@ -15,6 +15,7 @@ const NAV_ITEMS = [ { label: 'Tokens', path: 'tokens' }, { label: 'Teams', path: 'teams' }, { label: 'Service Accounts', path: 'service-accounts' }, + { label: 'Personal Access Tokens', path: 'pats' }, { label: 'Plans', path: 'plans' } ]; 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/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 b52fed342..01d9cb430 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 5ec5786a4..95e35896b 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 { FrontierServiceQueries } 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/index.ts b/web/sdk/react/index.ts index 6684c89fa..4c59d0730 100644 --- a/web/sdk/react/index.ts +++ b/web/sdk/react/index.ts @@ -45,6 +45,7 @@ export { ServiceAccountDetailsView } from './views-new/service-accounts'; export { PlansView } from './views-new/plans'; +export { PatsView, PATDetailsView } from './views-new/pat'; export type { FrontierClientOptions, 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..0c2af867b --- /dev/null +++ b/web/sdk/react/views-new/pat/components/pat-columns.tsx @@ -0,0 +1,71 @@ +'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 +}: { + 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 date = timestampToDayjs(row.original.expiresAt); + return date ? {date.format(dateFormat)} : null; + } + }, + { + header: 'Last used', + accessorKey: 'usedAt', + enableSorting: false, + cell: ({ row }) => { + const pat = row.original; + if (!pat.usedAt || isNullTimestamp(pat.usedAt)) return null; + const date = timestampToDayjs(pat.usedAt); + return date ? {date.fromNow()} : null; + } + }, + { + header: '', + accessorKey: 'id', + enableSorting: false, + styles: { + cell: { width: '73px' } + }, + cell: ({ row }) => ( + + ) + } + ]; +} 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..4bdca7eb7 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/pat-created-dialog.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { InfoCircledIcon } from '@radix-ui/react-icons'; +import { + Button, + Callout, + CopyButton, + Dialog, + Flex, + InputField, + Text +} from '@raystack/apsara-v1'; + +export interface PATCreatedPayload { + token: string; + isRegenerated?: boolean; +} + +export interface PATCreatedDialogProps { + handle: ReturnType>; + onClose?: () => void; +} + +export function PATCreatedDialog({ handle, onClose }: PATCreatedDialogProps) { + const handleOpenChange = (open: boolean) => { + if (!open) onClose?.(); + }; + + return ( + + {({ 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 new file mode 100644 index 000000000..e08a345d5 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/pat-form-dialog.tsx @@ -0,0 +1,637 @@ +'use client'; + +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'; +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, + Chip, + Dialog, + Flex, + InputField, + Label, + Radio, + Select, + Skeleton, + Spinner, + Text, + toastManager +} from '@raystack/apsara-v1'; +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 MAX_VISIBLE_PROJECT_CHIPS = 2; + +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()) + .default([]) +}; + +const createPATSchema = yup + .object({ + ...baseFields, + expiry: yup.string().required('Expiry date is required') + }) + .required(); + +const updatePATSchema = yup + .object({ + ...baseFields, + expiry: 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, + watch, + setValue, + setError, + clearErrors, + formState: { errors, isSubmitting, isDirty } + } = useForm({ + resolver: yupResolver(isUpdateMode ? updatePATSchema : createPATSchema), + defaultValues: { + title: '', + expiry: '', + orgRoleId: '', + projectRoleId: '', + projectIds: [] + } + }); + + // 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) { + 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) + ); + setProjectAccess(validProjectIds.length > 0 ? 'selective' : 'all'); + reset({ + title: initialData.title, + expiry: '', + orgRoleId: orgScope?.roleId || '', + projectRoleId: projectScope?.roleId || '', + projectIds: validProjectIds + }); + setTitleAvailable(true); + } + if (!open) { + reset(); + setTitleAvailable(null); + setProjectAccess('all'); + } + }; + + 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: rolesData, isLoading: isRolesLoading } = useQuery( + FrontierServiceQueries.listRolesForPAT, + create(ListRolesForPATRequestSchema, { scopes: [] }), + { enabled: Boolean(orgId) } + ); + + 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); + 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(() => { + if (isOrgAdmin) { + setProjectAccess('all'); + setValue('projectIds', []); + } + }, [isOrgAdmin, setValue]); + + 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; + } + + const checkedTitle = title; + setTitleChecking(true); + try { + const result = await checkTitle( + create(CheckCurrentUserPATTitleRequestSchema, { orgId, title }) + ); + if (getValues('title') === checkedTitle) { + setTitleAvailable(result?.available ?? true); + } + } catch { + // On check failure, don't block the user + if (getValues('title') === checkedTitle) { + setTitleAvailable(true); + } + } finally { + setTitleChecking(false); + } + }, [getValues, orgId, checkTitle, isUpdateMode, initialData]); + + const titleField = register('title'); + + const onSubmit = useCallback( + 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, + resourceType: PERMISSIONS.OrganizationNamespace, + resourceIds: [] as string[] + }, + { + roleId: data.projectRoleId, + resourceType: PERMISSIONS.ProjectNamespace, + resourceIds: projectAccess === 'all' ? [] : 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 option = EXPIRY_OPTIONS.find(o => o.value === data.expiry); + if (!option) return; + const expiresAt = timestampFromDate( + dayjs().add(option.amount, option.unit).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, + projectAccess, + setError + ] + ); + + const isDataLoading = isProjectsLoading || isRolesLoading; + + 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" + trailingIcon={titleChecking ? : undefined} + error={ + errors.title + ? String(errors.title?.message) + : titleAvailable === false + ? 'This name is already taken' + : undefined + } + data-test-id="frontier-sdk-pat-form-title-input" + /> + + {!isUpdateMode && ( + + + ( + + )} + /> + {errors.expiry && ( + + {String(errors.expiry?.message)} + + )} + + )} + + + + ( + + )} + /> + {errors.orgRoleId && ( + + {String(errors.orgRoleId?.message)} + + )} + + + + + ( + + )} + /> + {errors.projectRoleId && ( + + {String(errors.projectRoleId?.message)} + + )} + + + + + + { + const next = val as 'all' | 'selective'; + setProjectAccess(next); + if (next === 'all') { + setValue('projectIds', [], { + shouldDirty: true + }); + } + clearErrors('projectIds'); + }} + > + + + + + All + + + + + + Selective projects + + + + + + {projectAccess === 'selective' && ( + ( + + )} + /> + )} + {errors.projectIds && ( + + {String(errors.projectIds?.message)} + + )} + + + )} + + + + + + + + +
+
+
+ ); +} 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/components/regenerate-pat-dialog.module.css b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.module.css new file mode 100644 index 000000000..7b5f56a38 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.module.css @@ -0,0 +1,4 @@ +.body { + padding: var(--rs-space-9) var(--rs-space-7) !important; + gap: var(--rs-space-7) !important; +} diff --git a/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx new file mode 100644 index 000000000..18c0aaf4b --- /dev/null +++ b/web/sdk/react/views-new/pat/components/regenerate-pat-dialog.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import { create } from '@bufbuild/protobuf'; +import { timestampFromDate } from '@bufbuild/protobuf/wkt'; +import { useMutation } from '@connectrpc/connect-query'; +import dayjs from 'dayjs'; +import { + FrontierServiceQueries, + RegenerateCurrentUserPATRequestSchema +} from '@raystack/proton/frontier'; +import { + Button, + Dialog, + Flex, + Select, + Text, + toastManager +} from '@raystack/apsara-v1'; +import { useFrontier } from '~/react/contexts/FrontierContext'; +import { DEFAULT_DATE_FORMAT } from '~/react/utils/constants'; +import { handleConnectError } from '~/utils/error'; +import { EXPIRY_OPTIONS } from '../utils'; +import styles from './regenerate-pat-dialog.module.css'; + +export interface RegeneratePayload { + patId: string; + currentExpiryValue: string; +} + +export interface RegeneratePATDialogProps { + handle: ReturnType>; + onRegenerated?: (token: string) => void; +} + +export function RegeneratePATDialog({ + handle, + onRegenerated +}: RegeneratePATDialogProps) { + const { config } = useFrontier(); + const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT; + + const [expiryValue, setExpiryValue] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { mutateAsync: regeneratePAT } = useMutation( + FrontierServiceQueries.regenerateCurrentUserPAT + ); + + const handleOpenChange = (open: boolean) => { + if (!open) { + setExpiryValue(''); + } + }; + + const handleRegenerate = useCallback( + async (patId: string, selectedValue: string) => { + const option = EXPIRY_OPTIONS.find(o => o.value === selectedValue); + if (!option) return; + + setIsSubmitting(true); + try { + const expiresAt = timestampFromDate( + dayjs().add(option.amount, option.unit).toDate() + ); + + const response = await regeneratePAT( + create(RegenerateCurrentUserPATRequestSchema, { + id: patId, + expiresAt + }) + ); + + const token = response.pat?.token; + toastManager.add({ + title: 'Token regenerated', + type: 'success' + }); + handle.close(); + setExpiryValue(''); + if (token) onRegenerated?.(token); + } catch (error) { + handleConnectError(error, { + Default: err => + toastManager.add({ + title: 'Something went wrong', + description: err.message, + type: 'error' + }) + }); + } finally { + setIsSubmitting(false); + } + }, + [regeneratePAT, handle, onRegenerated] + ); + + return ( + + {({ payload }) => { + 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 replaced with a new + one. + + + + + + + + + + ); + }} + + ); +} diff --git a/web/sdk/react/views-new/pat/components/revoke-pat-dialog.module.css b/web/sdk/react/views-new/pat/components/revoke-pat-dialog.module.css new file mode 100644 index 000000000..6742c54f4 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/revoke-pat-dialog.module.css @@ -0,0 +1,3 @@ +.body { + border-bottom: none !important; +} diff --git a/web/sdk/react/views-new/pat/components/revoke-pat-dialog.tsx b/web/sdk/react/views-new/pat/components/revoke-pat-dialog.tsx new file mode 100644 index 000000000..6cccf0e96 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/revoke-pat-dialog.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useState } from 'react'; +import { create } from '@bufbuild/protobuf'; +import { useMutation } from '@connectrpc/connect-query'; +import { + FrontierServiceQueries, + DeleteCurrentUserPATRequestSchema +} from '@raystack/proton/frontier'; +import { AlertDialog, Button, toastManager } from '@raystack/apsara-v1'; +import styles from './revoke-pat-dialog.module.css'; +import { handleConnectError } from '~/utils/error'; + +export interface RevokePATDialogProps { + handle: ReturnType>; + onRevoked?: () => void; +} + +export function RevokePATDialog({ handle, onRevoked }: RevokePATDialogProps) { + const [isLoading, setIsLoading] = useState(false); + + const { mutateAsync: deletePAT } = useMutation( + FrontierServiceQueries.deleteCurrentUserPAT + ); + + const handleRevoke = async (patId: string) => { + setIsLoading(true); + try { + await deletePAT( + create(DeleteCurrentUserPATRequestSchema, { id: patId }) + ); + handle.close(); + toastManager.add({ title: 'Token revoked', type: 'success' }); + onRevoked?.(); + } catch (error) { + handleConnectError(error, { + Default: err => + toastManager.add({ + title: 'Something went wrong', + description: err.message, + type: 'error' + }) + }); + } finally { + setIsLoading(false); + } + }; + + return ( + + {({ payload: patId }) => ( + + + Revoke + + This action cannot be undone. Revoking this token will + permanently remove access for any users using it. You'll + need to generate a new token if access is required again. + + + + + + + + )} + + ); +} diff --git a/web/sdk/react/views-new/pat/components/token-cell.tsx b/web/sdk/react/views-new/pat/components/token-cell.tsx new file mode 100644 index 000000000..340a7abf6 --- /dev/null +++ b/web/sdk/react/views-new/pat/components/token-cell.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { Button, Flex, Text } from '@raystack/apsara-v1'; +import styles from '../pat-view.module.css'; + +function KeyIcon() { + return ( + + + + ); +} + +export interface TokenCellProps { + title: string; + expiry: string; + lastUsed: string; + onClick?: () => void; + onRevoke?: () => void; +} + +export function TokenCell({ title, expiry, lastUsed, onClick, onRevoke }: TokenCellProps) { + return ( + + +
+ +
+ + + {title} + + + {expiry && ( + + {expiry} + + )} + {expiry && lastUsed && ( + + • + + )} + {lastUsed && ( + + {lastUsed} + + )} + + +
+ +
+ ); +} diff --git a/web/sdk/react/views-new/pat/index.ts b/web/sdk/react/views-new/pat/index.ts new file mode 100644 index 000000000..f7ddb1865 --- /dev/null +++ b/web/sdk/react/views-new/pat/index.ts @@ -0,0 +1,3 @@ +export { PatsView } from './pat-view'; +export { PATDetailsView } from './pat-details-view'; +export type { PATDetailsViewProps } from './pat-details-view'; diff --git a/web/sdk/react/views-new/pat/pat-details-view.module.css b/web/sdk/react/views-new/pat/pat-details-view.module.css new file mode 100644 index 000000000..425d3d19b --- /dev/null +++ b/web/sdk/react/views-new/pat/pat-details-view.module.css @@ -0,0 +1,23 @@ +.section { + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + width: 100%; + display: flex; + flex-direction: column; + gap: var(--rs-space-6); + padding: var(--rs-space-5); +} + +.detailRow { + display: flex; + gap: var(--rs-space-3); + align-items: flex-start; +} + +.menuContent { + min-width: 160px; +} + +.callout { + width: 100%; +} diff --git a/web/sdk/react/views-new/pat/pat-details-view.tsx b/web/sdk/react/views-new/pat/pat-details-view.tsx new file mode 100644 index 000000000..a3b812beb --- /dev/null +++ b/web/sdk/react/views-new/pat/pat-details-view.tsx @@ -0,0 +1,387 @@ +'use client'; + +import { ReactNode, useCallback, useEffect, useMemo } from 'react'; +import { DotsHorizontalIcon } from '@radix-ui/react-icons'; +import { + AlertDialog, + Breadcrumb, + Button, + Dialog, + Flex, + IconButton, + Menu, + Skeleton, + Text, + toastManager +} from '@raystack/apsara-v1'; +import { useQuery } from '@connectrpc/connect-query'; +import { create } from '@bufbuild/protobuf'; +import { + FrontierServiceQueries, + GetCurrentUserPATRequestSchema, + ListRolesForPATRequestSchema, + ListOrganizationProjectsRequestSchema +} from '@raystack/proton/frontier'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { useFrontier } from '../../contexts/FrontierContext'; +import { ViewContainer } from '../../components/view-container'; +import { ViewHeader } from '../../components/view-header'; +import { DEFAULT_DATE_FORMAT } from '../../utils/constants'; +import { PERMISSIONS } from '../../../utils'; +import { isNullTimestamp, timestampToDayjs } from '~/utils/timestamp'; +import { + PATCreatedDialog, + type PATCreatedPayload +} from './components/pat-created-dialog'; +import { PATFormDialog } from './components/pat-form-dialog'; +import { PATProjectChips } from './components/pat-project-chips'; +import { + RegeneratePATDialog, + type RegeneratePayload +} from './components/regenerate-pat-dialog'; +import { RevokePATDialog } from './components/revoke-pat-dialog'; +import { getExpiryOptionValue, getExpiryReferenceDayjs } from './utils'; +import styles from './pat-details-view.module.css'; + +dayjs.extend(relativeTime); + +const updatePATDialogHandle = Dialog.createHandle(); +const regenerateDialogHandle = Dialog.createHandle(); +const patCreatedDialogHandle = Dialog.createHandle(); +const revokePATDialogHandle = AlertDialog.createHandle(); + +interface DetailRowProps { + label: string; + children: ReactNode; +} + +function DetailRow({ label, children }: DetailRowProps) { + return ( +
+ {label} + {typeof children === 'string' ? ( + + {children} + + ) : ( + children + )} +
+ ); +} + +export interface PATDetailsViewProps { + patId: string; + onNavigateToPats?: () => void; + onDeleteSuccess?: () => void; +} + +export function PATDetailsView({ + patId, + onNavigateToPats, + onDeleteSuccess +}: PATDetailsViewProps) { + const { activeOrganization: organization, config } = useFrontier(); + const orgId = organization?.id || ''; + const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT; + + const { + data: pat, + isLoading: isPatLoading, + error: patError, + refetch: refetchPat + } = useQuery( + FrontierServiceQueries.getCurrentUserPAT, + create(GetCurrentUserPATRequestSchema, { id: patId }), + { + enabled: Boolean(patId), + select: d => d?.pat + } + ); + + useEffect(() => { + if (patError) { + toastManager.add({ + title: 'Something went wrong', + description: patError.message, + type: 'error' + }); + } + }, [patError]); + + const { data: orgRolesData, isLoading: isOrgRolesLoading } = useQuery( + FrontierServiceQueries.listRolesForPAT, + create(ListRolesForPATRequestSchema, { scopes: [PERMISSIONS.OrganizationNamespace] }), + { enabled: Boolean(orgId) } + ); + const orgRoles = useMemo(() => orgRolesData?.roles ?? [], [orgRolesData]); + + const { data: projectRolesData, isLoading: isProjectRolesLoading } = + useQuery( + FrontierServiceQueries.listRolesForPAT, + create(ListRolesForPATRequestSchema, { scopes: [PERMISSIONS.ProjectNamespace] }), + { enabled: Boolean(orgId) } + ); + const projectRoles = useMemo( + () => projectRolesData?.roles ?? [], + [projectRolesData] + ); + + const { data: projectsData, isLoading: isProjectsLoading } = useQuery( + FrontierServiceQueries.listOrganizationProjects, + create(ListOrganizationProjectsRequestSchema, { + id: orgId, + state: '', + withMemberCount: false + }), + { enabled: Boolean(orgId) } + ); + const projects = useMemo( + () => projectsData?.projects ?? [], + [projectsData] + ); + + const isLoading = + !organization?.id || + isPatLoading || + isOrgRolesLoading || + isProjectRolesLoading || + isProjectsLoading; + + const orgScope = useMemo( + () => pat?.scopes?.find(s => s.resourceType === PERMISSIONS.OrganizationNamespace), + [pat] + ); + + const projectScope = useMemo( + () => pat?.scopes?.find(s => s.resourceType === PERMISSIONS.ProjectNamespace), + [pat] + ); + + const orgRoleName = useMemo(() => { + if (!orgScope) return ''; + const role = orgRoles.find(r => r.id === orgScope.roleId); + return role?.title || role?.name || ''; + }, [orgScope, orgRoles]); + + const projectRoleName = useMemo(() => { + if (!projectScope) return ''; + const role = projectRoles.find(r => r.id === projectScope.roleId); + return role?.title || role?.name || ''; + }, [projectScope, projectRoles]); + + const scopeProjects = useMemo(() => { + if (!projectScope?.resourceIds?.length) return []; + return projects + .filter(p => projectScope.resourceIds.includes(p.id || '')) + .map(p => ({ id: p.id || '', title: p.title || p.id || '' })); + }, [projectScope, projects]); + + const isAllProjects = + !projectScope?.resourceIds || projectScope.resourceIds.length === 0; + + const createdOn = useMemo(() => { + const d = timestampToDayjs(pat?.createdAt); + return d ? d.format(dateFormat) : ''; + }, [pat, dateFormat]); + + const lastUsed = useMemo(() => { + if (!pat?.usedAt || isNullTimestamp(pat.usedAt)) return ''; + const d = timestampToDayjs(pat.usedAt); + return d ? d.fromNow() : ''; + }, [pat]); + + const regeneratedOn = useMemo(() => { + if (!pat?.regeneratedAt || isNullTimestamp(pat.regeneratedAt)) return ''; + const d = timestampToDayjs(pat.regeneratedAt); + return d ? d.format(dateFormat) : ''; + }, [pat, dateFormat]); + + const { expiryInfo, currentExpiryValue } = useMemo(() => { + const reference = getExpiryReferenceDayjs(pat); + const expires = timestampToDayjs(pat?.expiresAt); + if (!reference || !expires) + return { expiryInfo: '', currentExpiryValue: '' }; + const days = expires.diff(reference, 'day'); + return { + expiryInfo: `${expires.format(dateFormat)} (${days} Days)`, + currentExpiryValue: getExpiryOptionValue(reference, expires) + }; + }, [pat, dateFormat]); + + const handleRegenerated = useCallback( + (token: string) => { + patCreatedDialogHandle.openWithPayload({ token, isRegenerated: true }); + }, + [] + ); + + const handleTokenDialogClose = () => { + refetchPat(); + }; + + const patTitle = pat?.title || ''; + + return ( + + + { + e.preventDefault(); + onNavigateToPats?.(); + }} + > + Personal access token + + + + {isPatLoading ? ( + + ) : ( + patTitle + )} + + + } + > + + + } + > + + + + 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)' }} + data-test-id="frontier-sdk-pat-revoke-menu-btn" + > + Revoke + + + + + + {isLoading ? ( + + + + + ) : ( + <> +
+ + + General + + + + + {createdOn && {createdOn}} + {lastUsed && {lastUsed}} + {orgRoleName && ( + {orgRoleName} + )} + {projectRoleName && ( + {projectRoleName} + )} + + {isAllProjects || scopeProjects.length === 0 ? ( + + All projects + + ) : ( + + )} + + +
+ +
+ + + Expiry Details + + + + + {expiryInfo && ( + {expiryInfo} + )} + {regeneratedOn && ( + {regeneratedOn} + )} + +
+ + )} + + + refetchPat()} + /> + + +
+ ); +} diff --git a/web/sdk/react/views-new/pat/pat-view.module.css b/web/sdk/react/views-new/pat/pat-view.module.css new file mode 100644 index 000000000..0420a1152 --- /dev/null +++ b/web/sdk/react/views-new/pat/pat-view.module.css @@ -0,0 +1,24 @@ +.tableRoot { + border: none; +} + +.revokeButton { + opacity: 0; + transition: opacity 0.15s; +} + +:global(tr):hover .revokeButton { + opacity: 1; +} +.projectChip { + max-width: var(--rs-space-16); + min-width: 0; + justify-content: flex-start; +} + +.projectChipLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/web/sdk/react/views-new/pat/pat-view.tsx b/web/sdk/react/views-new/pat/pat-view.tsx new file mode 100644 index 000000000..6a4fb2460 --- /dev/null +++ b/web/sdk/react/views-new/pat/pat-view.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useMemo } from 'react'; +import { LockClosedIcon } from '@radix-ui/react-icons'; +import { + AlertDialog, + Button, + DataTable, + Dialog, + EmptyState, + Flex, + Skeleton +} from '@raystack/apsara-v1'; +import type { DataTableQuery, DataTableSort } from '@raystack/apsara-v1'; +import { useDebouncedState } from '@raystack/apsara-v1/hooks'; +import { useInfiniteQuery } from '@connectrpc/connect-query'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { FrontierServiceQueries } from '@raystack/proton/frontier'; +import { useFrontier } from '../../contexts/FrontierContext'; +import { useTerminology } from '../../hooks/useTerminology'; +import { ViewContainer } from '../../components/view-container'; +import { ViewHeader } from '../../components/view-header'; +import { DEFAULT_DATE_FORMAT } from '../../utils/constants'; +import { + DEFAULT_PAGE_SIZE, + getConnectNextPageParam +} from '~/utils/connect-pagination'; +import { transformDataTableQueryToRQLRequest } from '~/utils/transform-query'; +import { getColumns } from './components/pat-columns'; +import { PATFormDialog } from './components/pat-form-dialog'; +import { + PATCreatedDialog, + type PATCreatedPayload +} from './components/pat-created-dialog'; +import { RevokePATDialog } from './components/revoke-pat-dialog'; +import styles from './pat-view.module.css'; + +dayjs.extend(relativeTime); + +const createPATDialogHandle = Dialog.createHandle(); +const patCreatedDialogHandle = Dialog.createHandle(); +const revokePATDialogHandle = AlertDialog.createHandle(); + +const DEFAULT_SORT: DataTableSort = { name: 'title', order: 'asc' }; +const INITIAL_QUERY: DataTableQuery = { + offset: 0, + limit: DEFAULT_PAGE_SIZE +}; +const TRANSFORM_OPTIONS = { + fieldNameMapping: { + createdAt: 'created_at', + updatedAt: 'updated_at', + expiresAt: 'expires_at', + usedAt: 'used_at' + } +}; + +export interface PatsViewProps { + onPATClick?: (patId: string) => void; +} + +export function PatsView({ onPATClick }: PatsViewProps = {}) { + const { + activeOrganization: organization, + isActiveOrganizationLoading, + config + } = useFrontier(); + const t = useTerminology(); + + const orgId = organization?.id ?? ''; + + const [tableQuery, setTableQuery] = useDebouncedState( + INITIAL_QUERY, + 200 + ); + + const query = useMemo( + () => transformDataTableQueryToRQLRequest(tableQuery, TRANSFORM_OPTIONS), + [tableQuery] + ); + + const { + data: infiniteData, + isLoading: isPatsLoading, + isFetchingNextPage, + fetchNextPage, + hasNextPage, + refetch + } = useInfiniteQuery( + FrontierServiceQueries.searchCurrentUserPATs, + { orgId, query }, + { + enabled: Boolean(orgId), + pageParamKey: 'query', + getNextPageParam: lastPage => + getConnectNextPageParam(lastPage, { query }, 'pats'), + staleTime: 0, + refetchOnWindowFocus: false + } + ); + + const pats = useMemo( + () => infiniteData?.pages?.flatMap(page => page?.pats ?? []) ?? [], + [infiniteData] + ); + + const hasActiveQuery = Boolean( + tableQuery.search || tableQuery.filters?.length + ); + const isInitialLoading = !organization?.id || isActiveOrganizationLoading; + const isTableLoading = isPatsLoading || isFetchingNextPage; + const hasNoPats = + !isInitialLoading && + !isPatsLoading && + !hasActiveQuery && + pats.length === 0; + + const dateFormat = config?.dateFormat || DEFAULT_DATE_FORMAT; + + const columns = useMemo( + () => + getColumns({ + dateFormat, + onRevoke: (patId: string) => + revokePATDialogHandle.openWithPayload(patId) + }), + [dateFormat] + ); + + const onTableQueryChange = (newQuery: DataTableQuery) => { + setTableQuery({ + ...newQuery, + offset: 0, + limit: newQuery.limit || DEFAULT_PAGE_SIZE + }); + }; + + const handleLoadMore = async () => { + if (hasNextPage && !isFetchingNextPage) { + await fetchNextPage(); + } + }; + + const handlePATCreated = (token: string) => { + patCreatedDialogHandle.openWithPayload({ token }); + }; + + const handleSuccessDialogClose = () => { + refetch(); + }; + + return ( + + + + {isInitialLoading ? ( + + + + + + + ) : hasNoPats ? ( + } + heading="No Personal Access Token Found" + subHeading={`Create a new to use the Keys of ${t.appName()} platform`} + primaryAction={ + + } + /> + ) : ( + onPATClick?.(row.id)} + > + + + + + + } + heading="No tokens matching your search" + /> + } + classNames={{ + root: styles.tableRoot + }} + /> + + + )} + + + + refetch()} + /> + + ); +} diff --git a/web/sdk/react/views-new/pat/utils.ts b/web/sdk/react/views-new/pat/utils.ts new file mode 100644 index 000000000..1824b050e --- /dev/null +++ b/web/sdk/react/views-new/pat/utils.ts @@ -0,0 +1,35 @@ +import dayjs, { type Dayjs } from 'dayjs'; +import type { PAT } from '@raystack/proton/frontier'; +import { isNullTimestamp, timestampToDayjs } from '~/utils/timestamp'; + +export const EXPIRY_OPTIONS = [ + { value: '1w', label: '1 week', amount: 1, unit: 'week' as const }, + { value: '1m', label: '1 month', amount: 1, unit: 'month' as const }, + { value: '3m', label: '3 months', amount: 3, unit: 'month' as const }, + { value: '6m', label: '6 months', amount: 6, unit: 'month' as const }, + { value: '12m', label: '12 months', amount: 12, unit: 'month' as const } +] as const; + +export type ExpiryOption = (typeof EXPIRY_OPTIONS)[number]; + +/** + * Reference timestamp for expiry math: regeneratedAt when present, else createdAt. + */ +export function getExpiryReferenceDayjs(pat?: PAT): Dayjs | null { + if (!pat) return null; + if (pat.regeneratedAt && !isNullTimestamp(pat.regeneratedAt)) { + return timestampToDayjs(pat.regeneratedAt); + } + return timestampToDayjs(pat.createdAt); +} + +export function getExpiryOptionValue( + reference?: dayjs.Dayjs | null, + expiresAt?: dayjs.Dayjs | null +): string { + if (!reference || !expiresAt) return ''; + const match = EXPIRY_OPTIONS.find(option => + reference.add(option.amount, option.unit).isSame(expiresAt, 'day') + ); + return match?.value ?? ''; +} diff --git a/web/sdk/admin/utils/connect-pagination.ts b/web/sdk/utils/connect-pagination.ts similarity index 100% rename from web/sdk/admin/utils/connect-pagination.ts rename to web/sdk/utils/connect-pagination.ts diff --git a/web/sdk/admin/utils/transform-query.ts b/web/sdk/utils/transform-query.ts similarity index 100% rename from web/sdk/admin/utils/transform-query.ts rename to web/sdk/utils/transform-query.ts