From c3cbe1d50d8f8e3dbe622b1e77e0e7c2cb2ac47e Mon Sep 17 00:00:00 2001 From: Charlie Park Date: Sun, 12 Oct 2025 06:02:18 -0400 Subject: [PATCH 01/28] Add SCIM tab to UI --- app/pages/system/silos/SiloPage.tsx | 3 +- app/pages/system/silos/SiloScimTab.tsx | 305 +++++++++++++++++++++++++ app/routes.tsx | 4 + app/ui/lib/Modal.tsx | 10 +- app/util/links.ts | 6 + app/util/path-builder.ts | 1 + mock-api/msw/db.ts | 1 + mock-api/msw/handlers.ts | 54 ++++- mock-api/silo.ts | 25 ++ scim-todos.txt | 3 + 10 files changed, 403 insertions(+), 9 deletions(-) create mode 100644 app/pages/system/silos/SiloScimTab.tsx create mode 100644 scim-todos.txt diff --git a/app/pages/system/silos/SiloPage.tsx b/app/pages/system/silos/SiloPage.tsx index 1088509fc..3f0b2b263 100644 --- a/app/pages/system/silos/SiloPage.tsx +++ b/app/pages/system/silos/SiloPage.tsx @@ -58,9 +58,10 @@ export default function SiloPage() { Identity Providers - IP Pools + Linked Pools Quotas Fleet roles + SCIM ) diff --git a/app/pages/system/silos/SiloScimTab.tsx b/app/pages/system/silos/SiloScimTab.tsx new file mode 100644 index 000000000..9e87a231f --- /dev/null +++ b/app/pages/system/silos/SiloScimTab.tsx @@ -0,0 +1,305 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' +import { useCallback, useMemo, useState } from 'react' +import { type LoaderFunctionArgs } from 'react-router' + +import { AccessToken24Icon, OpenLink12Icon } from '@oxide/design-system/icons/react' + +import { + apiQueryClient, + useApiMutation, + usePrefetchedApiQuery, + type ScimClientBearerToken, +} from '~/api' +import { getSiloSelector, useSiloSelector } from '~/hooks/use-params' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { Table } from '~/table/Table' +import { Badge } from '~/ui/lib/Badge' +import { Button } from '~/ui/lib/Button' +import { CardBlock } from '~/ui/lib/CardBlock' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' +import { CreateButton } from '~/ui/lib/CreateButton' +import { DateTime } from '~/ui/lib/DateTime' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Message } from '~/ui/lib/Message' +import { Modal } from '~/ui/lib/Modal' +import { TableEmptyBox } from '~/ui/lib/Table' +import { Tooltip } from '~/ui/lib/Tooltip' +import { docLinks } from '~/util/links' + +const colHelper = createColumnHelper() + +const EmptyState = () => ( + + } + title="No SCIM tokens" + body="Create a token to see it here" + /> + +) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { silo } = getSiloSelector(params) + await apiQueryClient.prefetchQuery('scimTokenList', { query: { silo } }) + return null +} + +export default function SiloScimTab() { + const siloSelector = useSiloSelector() + const { data: tokens } = usePrefetchedApiQuery('scimTokenList', { + query: { silo: siloSelector.silo }, + }) + + const [showCreateModal, setShowCreateModal] = useState(false) + const [createdToken, setCreatedToken] = useState<{ + id: string + bearerToken: string + timeCreated: Date + timeExpires?: Date | null + } | null>(null) + + const deleteToken = useApiMutation('scimTokenDelete', { + onSuccess() { + apiQueryClient.invalidateQueries('scimTokenList') + }, + }) + + const deleteAllTokens = useApiMutation('scimTokenDeleteAll', { + onSuccess() { + apiQueryClient.invalidateQueries('scimTokenList') + }, + }) + + const makeActions = useCallback( + (token: ScimClientBearerToken): MenuAction[] => [ + { + label: 'Delete', + onActivate: confirmDelete({ + doDelete: () => + deleteToken.mutateAsync({ + path: { tokenId: token.id }, + query: { silo: siloSelector.silo }, + }), + label: token.id, + }), + }, + ], + [deleteToken, siloSelector.silo] + ) + + const staticColumns = useMemo( + () => [ + colHelper.accessor('id', { + header: 'ID', + cell: (info) => { + const id = info.getValue() + return ( + + + {id.slice(0, 8)}...{id.slice(-6)} + + + ) + }, + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + colHelper.accessor('timeExpires', { + header: 'Expires', + cell: (info) => { + const expires = info.getValue() + return expires ? ( + + ) : ( + Never + ) + }, + }), + ], + [] + ) + + const columns = useColsWithActions(staticColumns, makeActions, 'Copy token ID') + + const table = useReactTable({ + data: tokens, + columns, + getCoreRowModel: getCoreRowModel(), + }) + const { href, linkText } = docLinks.scim + return ( + <> + + + {tokens.length > 0 && ( + + )} + setShowCreateModal(true)}>Create token + + + {tokens.length === 0 ? ( + + ) : ( + + )} + + +
+ + Learn more about{' '} + + {linkText} + + + +
+
+ + + {showCreateModal && ( + setShowCreateModal(false)} + onSuccess={(token) => { + setShowCreateModal(false) + setCreatedToken(token) + }} + /> + )} + + {createdToken && ( + setCreatedToken(null)} /> + )} + + ) +} + +function CreateTokenModal({ + siloSelector, + onDismiss, + onSuccess, +}: { + siloSelector: { silo: string } + onDismiss: () => void + onSuccess: (token: { + id: string + bearerToken: string + timeCreated: Date + timeExpires?: Date | null + }) => void +}) { + const createToken = useApiMutation('scimTokenCreate', { + onSuccess(token) { + apiQueryClient.invalidateQueries('scimTokenList') + onSuccess(token) + }, + onError(err) { + addToast({ variant: 'error', title: 'Failed to create token', content: err.message }) + }, + }) + + return ( + + + + + + { + createToken.mutate({ query: { silo: siloSelector.silo } }) + }} + actionText="Create" + actionLoading={createToken.isPending} + /> + + ) +} + +function TokenCreatedModal({ + token, + onDismiss, +}: { + token: { + id: string + bearerToken: string + timeCreated: Date + timeExpires?: Date | null + } + onDismiss: () => void +}) { + return ( + + + {token.timeExpires && ( +
+
Expires
+ +
+ )} + + + +
+
Bearer Token
+
+
+ {token.bearerToken} +
+
+ +
+
+
+
+ + +
+ ) +} diff --git a/app/routes.tsx b/app/routes.tsx index a085800cf..8c6d1bd5d 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -154,6 +154,10 @@ export const routes = createRoutesFromElements( path="fleet-roles" lazy={() => import('./pages/system/silos/SiloFleetRolesTab').then(convert)} /> + import('./pages/system/silos/SiloScimTab').then(convert)} + /> diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index 9247c40b7..afb0da167 100644 --- a/app/ui/lib/Modal.tsx +++ b/app/ui/lib/Modal.tsx @@ -113,6 +113,7 @@ type FooterProps = { actionLoading?: boolean cancelText?: string disabled?: boolean + showCancel?: boolean } & MergeExclusive<{ formId: string }, { onAction: () => void }> Modal.Footer = ({ @@ -125,13 +126,16 @@ Modal.Footer = ({ cancelText, disabled, formId, + showCancel = true, }: FooterProps) => (