From d67c5cd9e4da22cc14f02defe089d297f72352b7 Mon Sep 17 00:00:00 2001 From: hujincalrin41 Date: Mon, 13 Jul 2026 08:47:01 +0000 Subject: [PATCH 1/4] feat(ui): External Data, Users & permissions for issue #2 Adds three top-nav areas to the UC UI: - External Data: two tabs (External Locations / Credentials) with list + detail pages. Credential detail shows type (Aliyun STS / Aliyun static AK/SK / AWS IAM), a masked access-key id (the secret is never returned by the API), the STS trust-policy hint, and "used by" locations. Location detail links back to its credential. Both support Create (secret entered via a password field; server enforces CREATE_STORAGE_CREDENTIAL / CREATE_EXTERNAL_LOCATION and its 403 is surfaced verbatim). - A per-credential Validate action vends temporary PATH_READ credentials for a bound external location, probing the real vend path; the notification is scoped to the exact path probed. - Users: SCIM user list (search by name OR email) with a detail drawer, Create User, and a per-user permissions view. A Grant/Revoke UI (securable- and user-scoped) rounds out permission management. Shared infra used across these lists: - ListLayout paginates client-side with a 20/50/100 page-size selector (default 20) and renders a failed load as an alert instead of an empty table; an optional searchText accessor lets Users search email too. - List hooks follow pagination to the end (credentials/external-locations via next_page_token, SCIM users via startIndex) so nothing is silently truncated at the server's page cap. - openapi route() percent-encodes path params (names with '/', '%', ...) via a function replacer, and the per-user permissions query filters assignments to the target principal client-side (the server ignores ?principal) and surfaces a total failure as an error rather than "no privileges". - Queries no longer retry (a 401/403/404 is deterministic; retries only delayed the error state). Co-Authored-By: Claude Opus 4.8 --- ui/src/App.tsx | 56 +- .../credentials/CredentialsList.tsx | 90 ++ .../credentials/ValidateCredentialButton.tsx | 81 ++ .../ExternalLocationsList.tsx | 76 + .../components/layouts/ListLayout.module.css | 4 +- ui/src/components/layouts/ListLayout.tsx | 35 +- .../modals/CreateCredentialModal.tsx | 171 +++ .../modals/CreateExternalLocationModal.tsx | 115 ++ ui/src/components/modals/CreateUserModal.tsx | 89 ++ .../permissions/GrantPermissionModal.tsx | 260 ++++ .../permissions/PermissionsPanel.tsx | 137 ++ ui/src/components/users/UserPermissions.tsx | 170 +++ ui/src/hooks/credentials.ts | 131 ++ ui/src/hooks/externalLocations.ts | 136 ++ ui/src/hooks/pathCredentials.ts | 45 + ui/src/hooks/permissions.ts | 234 +++ ui/src/hooks/users.ts | 119 ++ ui/src/pages/CredentialDetails.tsx | 250 ++++ ui/src/pages/ExternalData.tsx | 37 + ui/src/pages/ExternalLocationDetails.tsx | 145 ++ ui/src/pages/UsersList.tsx | 195 +++ ui/src/types/api/catalog.gen.ts | 1263 +++++++++++++++-- ui/src/utils/credential.ts | 43 + ui/src/utils/externalLocation.ts | 37 + ui/src/utils/openapi.ts | 11 +- ui/src/utils/paginate.ts | 28 + 26 files changed, 3818 insertions(+), 140 deletions(-) create mode 100644 ui/src/components/credentials/CredentialsList.tsx create mode 100644 ui/src/components/credentials/ValidateCredentialButton.tsx create mode 100644 ui/src/components/externalLocations/ExternalLocationsList.tsx create mode 100644 ui/src/components/modals/CreateCredentialModal.tsx create mode 100644 ui/src/components/modals/CreateExternalLocationModal.tsx create mode 100644 ui/src/components/modals/CreateUserModal.tsx create mode 100644 ui/src/components/permissions/GrantPermissionModal.tsx create mode 100644 ui/src/components/permissions/PermissionsPanel.tsx create mode 100644 ui/src/components/users/UserPermissions.tsx create mode 100644 ui/src/hooks/credentials.ts create mode 100644 ui/src/hooks/externalLocations.ts create mode 100644 ui/src/hooks/pathCredentials.ts create mode 100644 ui/src/hooks/permissions.ts create mode 100644 ui/src/hooks/users.ts create mode 100644 ui/src/pages/CredentialDetails.tsx create mode 100644 ui/src/pages/ExternalData.tsx create mode 100644 ui/src/pages/ExternalLocationDetails.tsx create mode 100644 ui/src/pages/UsersList.tsx create mode 100644 ui/src/utils/credential.ts create mode 100644 ui/src/utils/externalLocation.ts create mode 100644 ui/src/utils/paginate.ts diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1a21173..f8ff195 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -10,8 +10,10 @@ import { } from 'antd'; import { createBrowserRouter, + Navigate, Outlet, RouterProvider, + useLocation, useNavigate, } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -30,6 +32,10 @@ import Login from './pages/Login'; import { AuthProvider, useAuth } from './context/auth-context'; import { UserOutlined } from '@ant-design/icons'; import ModelVersionDetails from './pages/ModelVersionDetails'; +import ExternalData from './pages/ExternalData'; +import CredentialDetails from './pages/CredentialDetails'; +import ExternalLocationDetails from './pages/ExternalLocationDetails'; +import UsersList from './pages/UsersList'; // TODO: // As of [19/02/2025], this implementation should be updated once the following PR are merged. @@ -73,6 +79,30 @@ const router = createBrowserRouter([ path: '/models/:catalog/:schema/:model/versions/:version', element: , }, + { + path: '/external-data', + element: , + }, + { + path: '/external-data/external-locations', + element: , + }, + { + path: '/external-data/credentials', + element: , + }, + { + path: '/external-data/external-locations/:name', + element: , + }, + { + path: '/external-data/credentials/:name', + element: , + }, + { + path: '/users', + element: , + }, ], }, ]); @@ -80,6 +110,13 @@ const router = createBrowserRouter([ function AppProvider() { const { logout, currentUser } = useAuth(); const navigate = useNavigate(); + const { pathname } = useLocation(); + + const selectedNavKey = pathname.startsWith('/external-data') + ? 'external-data' + : pathname.startsWith('/users') + ? 'users' + : 'catalogs'; const profileMenuItems = useMemo( (): MenuProps['items'] => [ @@ -146,13 +183,23 @@ function AppProvider() { navigate('/'), }, + { + key: 'external-data', + label: 'External Data', + onClick: () => navigate('/external-data'), + }, + { + key: 'users', + label: 'Users', + onClick: () => navigate('/users'), + }, ]} style={{ flex: 1, minWidth: 0 }} /> @@ -215,7 +262,12 @@ function AppProvider() { function App() { const queryClient = new QueryClient({ - defaultOptions: { queries: { staleTime: QUERY_STALE_TIME } }, + defaultOptions: { + // No retry: these are authenticated admin CRUD reads — a 401/403/404 is + // deterministic, and the default 3 retries just delayed the error state + // by several seconds of blank UI. + queries: { staleTime: QUERY_STALE_TIME, retry: false }, + }, }); return authEnabled ? ( diff --git a/ui/src/components/credentials/CredentialsList.tsx b/ui/src/components/credentials/CredentialsList.tsx new file mode 100644 index 0000000..8162761 --- /dev/null +++ b/ui/src/components/credentials/CredentialsList.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Button, Tag, Typography } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import ListLayout from '../layouts/ListLayout'; +import { formatTimestamp } from '../../utils/formatTimestamp'; +import { + CredentialInterface, + useListCredentials, +} from '../../hooks/credentials'; +import { useListExternalLocations } from '../../hooks/externalLocations'; +import { credentialIdentityOf, credentialTypeOf } from '../../utils/credential'; +import { CreateCredentialModal } from '../modals/CreateCredentialModal'; +import ValidateCredentialButton from './ValidateCredentialButton'; + +const TYPE_COLORS: Record = { + 'Aliyun RAM (STS)': 'orange', + 'Aliyun AK/SK (static)': 'gold', + 'AWS IAM role': 'geekblue', +}; + +export default function CredentialsList() { + const { data, isLoading, error } = useListCredentials(); + const { data: locationsData } = useListExternalLocations(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const externalLocations = locationsData?.external_locations ?? []; + + return ( + <> + + loading={isLoading} + error={error} + title={ +
+ +
+ } + data={data?.credentials} + onRowClick={(record) => + navigate(`/external-data/credentials/${record.name}`) + } + rowKey={(record) => `credential-${record.id}`} + columns={[ + { title: 'Name', dataIndex: 'name', key: 'name', width: '25%' }, + { + title: 'Type', + key: 'type', + width: '15%', + render: (_, record) => { + const type = credentialTypeOf(record); + return {type}; + }, + }, + { + title: 'Role ARN / Access key', + key: 'identity', + width: '30%', + render: (_, record) => ( + + {credentialIdentityOf(record)} + + ), + }, + { title: 'Owner', dataIndex: 'owner', key: 'owner', width: '10%' }, + { + title: 'Created At', + dataIndex: 'created_at', + key: 'created_at', + width: '12%', + render: (value) => (value ? formatTimestamp(value) : ''), + }, + { + title: 'Actions', + key: 'actions', + width: '8%', + render: (_, record) => ( + + ), + }, + ]} + /> + setOpen(false)} /> + + ); +} diff --git a/ui/src/components/credentials/ValidateCredentialButton.tsx b/ui/src/components/credentials/ValidateCredentialButton.tsx new file mode 100644 index 0000000..fd8b6c1 --- /dev/null +++ b/ui/src/components/credentials/ValidateCredentialButton.tsx @@ -0,0 +1,81 @@ +import { Button, Tooltip } from 'antd'; +import { SafetyCertificateOutlined } from '@ant-design/icons'; +import { CredentialInterface } from '../../hooks/credentials'; +import { ExternalLocationInterface } from '../../hooks/externalLocations'; +import { useVendPathCredentials } from '../../hooks/pathCredentials'; +import { useNotification } from '../../utils/NotificationContext'; +import { PathOperation } from '../../types/api/catalog.gen'; + +interface ValidateCredentialButtonProps { + credential: CredentialInterface; + externalLocations: ExternalLocationInterface[]; + size?: 'small' | 'middle'; +} + +/** + * Validates that the Unity Catalog server can actually use a credential + * (i.e. assume the RAM/IAM role, or use the static AK/SK) by vending + * temporary PATH_READ credentials for an external location bound to it — + * the exact server-side flow real table access uses. Requires the + * credential to be bound to at least one external location. + */ +export default function ValidateCredentialButton({ + credential, + externalLocations, + size = 'small', +}: ValidateCredentialButtonProps) { + const mutation = useVendPathCredentials(); + const { setNotification } = useNotification(); + + const boundLocation = externalLocations.find( + (location) => location.credential_name === credential.name, + ); + + return ( + + + + ); +} diff --git a/ui/src/components/externalLocations/ExternalLocationsList.tsx b/ui/src/components/externalLocations/ExternalLocationsList.tsx new file mode 100644 index 0000000..27b8aee --- /dev/null +++ b/ui/src/components/externalLocations/ExternalLocationsList.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react'; +import { Button, Typography } from 'antd'; +import { Link, useNavigate } from 'react-router-dom'; +import ListLayout from '../layouts/ListLayout'; +import { formatTimestamp } from '../../utils/formatTimestamp'; +import { + ExternalLocationInterface, + useListExternalLocations, +} from '../../hooks/externalLocations'; +import { CreateExternalLocationModal } from '../modals/CreateExternalLocationModal'; + +export default function ExternalLocationsList() { + const { data, isLoading, error } = useListExternalLocations(); + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + + return ( + <> + + loading={isLoading} + error={error} + title={ +
+ +
+ } + data={data?.external_locations} + onRowClick={(record) => + navigate(`/external-data/external-locations/${record.name}`) + } + rowKey={(record) => `external-location-${record.id}`} + columns={[ + { title: 'Name', dataIndex: 'name', key: 'name', width: '20%' }, + { + title: 'URL', + dataIndex: 'url', + key: 'url', + width: '35%', + render: (value) => {value}, + }, + { + title: 'Credential', + dataIndex: 'credential_name', + key: 'credential_name', + width: '15%', + render: (value) => + value ? ( + e.stopPropagation()} + > + {value} + + ) : ( + '' + ), + }, + { title: 'Owner', dataIndex: 'owner', key: 'owner', width: '15%' }, + { + title: 'Created At', + dataIndex: 'created_at', + key: 'created_at', + width: '15%', + render: (value) => (value ? formatTimestamp(value) : ''), + }, + ]} + /> + setOpen(false)} + /> + + ); +} diff --git a/ui/src/components/layouts/ListLayout.module.css b/ui/src/components/layouts/ListLayout.module.css index 238ee18..1a4fe61 100644 --- a/ui/src/components/layouts/ListLayout.module.css +++ b/ui/src/components/layouts/ListLayout.module.css @@ -1,5 +1,5 @@ .clickableListLayout { - [class~=ant-table-row] { + [class~='ant-table-row'] { cursor: pointer; } -} \ No newline at end of file +} diff --git a/ui/src/components/layouts/ListLayout.tsx b/ui/src/components/layouts/ListLayout.tsx index 090b9a7..532edff 100644 --- a/ui/src/components/layouts/ListLayout.tsx +++ b/ui/src/components/layouts/ListLayout.tsx @@ -1,5 +1,6 @@ import { SearchOutlined } from '@ant-design/icons'; import { + Alert, Col, Flex, Input, @@ -12,6 +13,10 @@ import { AnyObject } from 'antd/es/_util/type'; import { ReactNode, useMemo, useState } from 'react'; import styles from './ListLayout.module.css'; +// Page-size options shown by the table's size changer, with 20 as the default. +export const PAGE_SIZE_OPTIONS = [20, 50, 100]; +const DEFAULT_PAGE_SIZE = 20; + interface ListLayoutProps { data: T[] | undefined; columns: TableColumnsType; @@ -21,6 +26,12 @@ interface ListLayoutProps { loading?: boolean; filters?: ReactNode; showSearch?: boolean; + // Error from the data query; when set, an alert replaces the table so a + // failed load is never shown as an empty ("no data") list. + error?: Error | null; + // Text a row is matched against by the search box. Defaults to the row's + // name; pass a custom accessor to also search other fields (e.g. email). + searchText?: (item: T) => string; } export default function ListLayout({ @@ -32,16 +43,21 @@ export default function ListLayout({ loading, filters, showSearch = true, + error, + searchText, }: ListLayoutProps) { const [filterValue, setFilterValue] = useState(''); - const [pageSize, setPageSize] = useState(10); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); const filteredData = useMemo(() => { if (!filterValue) return data; + const needle = filterValue.toLowerCase(); return data?.filter((item) => - String(item.name).toLowerCase().includes(filterValue.toLowerCase()), + (searchText ? searchText(item) : String(item.name)) + .toLowerCase() + .includes(needle), ); - }, [data, filterValue]); + }, [data, filterValue, searchText]); const onShowSizeChange = (current: number, pageSize: number) => { setPageSize(pageSize); @@ -50,6 +66,14 @@ export default function ListLayout({ return ( {title} + {error && ( + + )} {showSearch && ( ({ rowKey={rowKey} loading={loading} className={onRowClick ? styles.clickableListLayout : undefined} - dataSource={filteredData} + dataSource={error ? [] : filteredData} columns={columns} pagination={{ - hideOnSinglePage: true, pageSize: pageSize, + showSizeChanger: true, + pageSizeOptions: PAGE_SIZE_OPTIONS, onShowSizeChange: onShowSizeChange, showTotal: (total, range) => `${range[0]}-${range[1]} of ${total}`, }} diff --git a/ui/src/components/modals/CreateCredentialModal.tsx b/ui/src/components/modals/CreateCredentialModal.tsx new file mode 100644 index 0000000..7fe27d5 --- /dev/null +++ b/ui/src/components/modals/CreateCredentialModal.tsx @@ -0,0 +1,171 @@ +import { Button, Form, Input, Modal, Radio, Typography } from 'antd'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import TextArea from 'antd/es/input/TextArea'; +import { useNavigate } from 'react-router-dom'; +import { useNotification } from '../../utils/NotificationContext'; +import { useCreateCredential } from '../../hooks/credentials'; +import { CredentialPurpose } from '../../types/api/catalog.gen'; + +type CredentialType = 'aliyun_sts' | 'aliyun_static' | 'aws_iam'; + +interface CreateCredentialFormValues { + name: string; + comment?: string; + role_arn?: string; + access_key_id?: string; + access_key_secret?: string; +} + +interface CreateCredentialModalProps { + open: boolean; + closeModal: () => void; +} + +export function CreateCredentialModal({ + open, + closeModal, +}: CreateCredentialModalProps) { + const navigate = useNavigate(); + const mutation = useCreateCredential(); + const { setNotification } = useNotification(); + const submitRef = useRef(null); + const [credentialType, setCredentialType] = + useState('aliyun_sts'); + + // destroyOnClose resets the form fields but not this local state; re-default + // the type each time the modal opens so it doesn't reveal the prior choice. + useEffect(() => { + if (open) setCredentialType('aliyun_sts'); + }, [open]); + + const handleSubmit = useCallback(() => { + submitRef.current?.click(); + }, []); + + return ( + Create credential} + okText="Create" + cancelText="Cancel" + open={open} + destroyOnClose + onCancel={closeModal} + onOk={handleSubmit} + okButtonProps={{ loading: mutation.isPending }} + > + + Register a storage credential used to vend temporary credentials for + external locations. Creation requires metastore OWNER or the + CREATE_STORAGE_CREDENTIAL privilege; the server enforces this on submit. + + + layout="vertical" + onFinish={(values) => { + mutation.mutate( + { + name: values.name, + comment: values.comment, + purpose: CredentialPurpose.STORAGE, + ...(credentialType === 'aws_iam' + ? { aws_iam_role: { role_arn: values.role_arn ?? '' } } + : credentialType === 'aliyun_sts' + ? { aliyun_ram_role: { role_arn: values.role_arn } } + : { + aliyun_ram_role: { + access_key_id: values.access_key_id, + access_key_secret: values.access_key_secret, + }, + }), + }, + { + onError: (error: Error) => { + setNotification(error.message, 'error'); + }, + onSuccess: (credential) => { + setNotification( + `Credential ${credential.name} created`, + 'success', + ); + closeModal(); + navigate(`/external-data/credentials/${credential.name}`); + }, + }, + ); + }} + name="Create credential form" + > + Name} + name="name" + rules={[{ required: true, message: 'Name is required' }]} + > + + + Type}> + setCredentialType(e.target.value)} + options={[ + { value: 'aliyun_sts', label: 'Aliyun RAM role (STS)' }, + { value: 'aliyun_static', label: 'Aliyun static AK/SK' }, + { value: 'aws_iam', label: 'AWS IAM role' }, + ]} + /> + + {credentialType !== 'aliyun_static' && ( + Role ARN} + name="role_arn" + rules={[{ required: true, message: 'Role ARN is required' }]} + > + :role/' + : 'arn:aws:iam:::role/' + } + /> + + )} + {credentialType === 'aliyun_static' && ( + <> + Access key ID} + name="access_key_id" + rules={[{ required: true, message: 'Access key ID is required' }]} + > + + + Access key secret + } + name="access_key_secret" + rules={[ + { required: true, message: 'Access key secret is required' }, + ]} + extra="Stored server-side and never returned by the API; responses always redact it." + > + + + + )} + Comment} + name="comment" + > +