diff --git a/app/forms/anti-affinity-group-member-add.tsx b/app/forms/anti-affinity-group-member-add.tsx index bc0eec6e1..3aeee4089 100644 --- a/app/forms/anti-affinity-group-member-add.tsx +++ b/app/forms/anti-affinity-group-member-add.tsx @@ -63,9 +63,9 @@ export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }: -

+

Select an instance to add to the anti-affinity group{' '} - {antiAffinityGroup}. Only stopped instances can be added to the group. + {antiAffinityGroup}.

- {identityMode === 'saml_jit' && ( + {match(identityMode) + .with('saml_jit', () => true) + .with('saml_scim', () => true) + .with('local_only', () => false) + .exhaustive() && ( )} diff --git a/app/pages/system/silos/SiloPage.tsx b/app/pages/system/silos/SiloPage.tsx index 1088509fc..b23597e63 100644 --- a/app/pages/system/silos/SiloPage.tsx +++ b/app/pages/system/silos/SiloPage.tsx @@ -61,6 +61,7 @@ export default function SiloPage() { IP 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..e23333745 --- /dev/null +++ b/app/pages/system/silos/SiloScimTab.tsx @@ -0,0 +1,264 @@ +/* + * 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 } from '@oxide/design-system/icons/react' +import { Badge } from '@oxide/design-system/ui' + +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 { 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 { Truncate } from '~/ui/lib/Truncate' + +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 } = usePrefetchedApiQuery('scimTokenList', { + query: { silo: siloSelector.silo }, + }) + + // Order tokens by creation date, oldest first + const tokens = useMemo( + () => [...data].sort((a, b) => a.timeCreated.getTime() - b.timeCreated.getTime()), + [data] + ) + + 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 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) => ( + + ), + }), + colHelper.accessor('timeCreated', Columns.timeCreated), + colHelper.accessor('timeExpires', { + header: 'Expires', + cell: (info) => { + const expires = info.getValue() + return expires ? ( + + ) : ( + Never + ) + }, + meta: { thClassName: 'lg:w-1/4' }, + }), + ], + [] + ) + + const columns = useColsWithActions(staticColumns, makeActions, 'Copy token ID') + + const table = useReactTable({ + data: tokens, + columns, + getCoreRowModel: getCoreRowModel(), + }) + // const { href, linkText } = docLinks.scim + return ( + <> + + + setShowCreateModal(true)}>Create token + + + {tokens.length === 0 ? ( + + ) : ( + + )} + + {/* TODO: put this back! + + + */} + + + {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 ( + + + Anyone with this token can manage users and groups in this silo via SCIM. Since + group membership grants roles, this token can be used to give a user admin + privileges. Store it securely and never share it publicly. + + + { + 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 ( + + + + This is the only time you’ll see this token. Copy it now and store it securely. + + /> + +
+
Bearer Token
+
+
+ {token.bearerToken} +
+
+ +
+
+
+
+ + +
+ ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 112e3c623..8a16d50b5 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/Message.tsx b/app/ui/lib/Message.tsx index 5971caeef..81eb6198b 100644 --- a/app/ui/lib/Message.tsx +++ b/app/ui/lib/Message.tsx @@ -38,17 +38,17 @@ const defaultIcon: Record = { } const color: Record = { - success: 'bg-accent-secondary', - error: 'bg-error-secondary', - notice: 'bg-notice-secondary', - info: 'bg-info-secondary', + success: 'bg-accent-secondary border-accent/10', + error: 'bg-error-secondary border-destructive/10', + notice: 'bg-notice-secondary border-notice/10', + info: 'bg-info-secondary border-blue-800/10', } const textColor: Record = { - success: 'text-accent *:text-accent', - error: 'text-error *:text-error', - notice: 'text-notice *:text-notice', - info: 'text-info *:text-info', + success: 'text-accent', + error: 'text-error', + notice: 'text-notice', + info: 'text-info', } const secondaryTextColor: Record = { @@ -77,22 +77,26 @@ export const Message = ({ return (
{showIcon && ( -
{defaultIcon[variant]}
+
svg]:h-3 [&>svg]:w-3', + `[&>svg]:${textColor[variant]}` + )} + > + {defaultIcon[variant]} +
)}
{title &&
{title}
}
a]:underline', - title ? secondaryTextColor[variant] : textColor[variant] - )} + className={cn('text-sans-md [&>a]:tint-underline', secondaryTextColor[variant])} > {content}
diff --git a/app/ui/lib/Modal.tsx b/app/ui/lib/Modal.tsx index b80877b50..0f90eb8b9 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) => (
{children}
- + {showCancel && ( + + )}