Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c3cbe1d
Add SCIM tab to UI
charliepark Oct 12, 2025
781338f
Add tests
charliepark Oct 12, 2025
2e9fd5d
Tweak truncation
charliepark Oct 12, 2025
711e286
Use 90 day default for expiration time in mock data
charliepark Oct 13, 2025
9a5b622
revert UI copy change
charliepark Oct 13, 2025
5d0ec9d
TODOs update
charliepark Oct 13, 2025
ddc38a0
Remove Delete All from UI and set handler to NotImplemented
charliepark Oct 13, 2025
bcaede8
Merge branch 'main' into scim-ui
charliepark Oct 14, 2025
72b0478
npm i
charliepark Oct 14, 2025
1f9d238
update npm and re-run npm i
charliepark Oct 14, 2025
f8da78a
merge main and resolve conflicts
charliepark Oct 15, 2025
9c180b5
Remove todo list
charliepark Oct 15, 2025
5ccfbd5
Update import and className ordering
charliepark Oct 15, 2025
732151c
Update path snapshots
charliepark Oct 15, 2025
366107f
tweak height of token pseudo input; order tokens by created_at
charliepark Oct 15, 2025
47e43af
Remove EXPIRES column and content from confirmation modal
charliepark Oct 15, 2025
6769f2f
Simplify mock handler
charliepark Oct 15, 2025
69283da
Revert "Remove EXPIRES column and content from confirmation modal"
charliepark Oct 15, 2025
e50b8dd
Set mock token data to have null expiration datetime
charliepark Oct 15, 2025
cbe165e
Hide 'Learn about SCIM Auth' text until we have documentation up that…
charliepark Oct 16, 2025
31dbaae
Remove unnecessary test
charliepark Oct 16, 2025
fe07125
For desktops, give a min-width to the Expires column so it doesn't fe…
charliepark Oct 16, 2025
8b04094
merge main
david-crespo Oct 24, 2025
d7a7f28
SCIM UI tweaks
benjaminleonard Oct 27, 2025
373cf62
Fix funky modal (unrelated to SCIM)
benjaminleonard Oct 27, 2025
bb1b6ef
Message spacing tweak
benjaminleonard Oct 27, 2025
0ffff24
Unify input label with others
benjaminleonard Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/forms/anti-affinity-group-member-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,9 @@ export default function AddAntiAffinityGroupMemberForm({ instances, onDismiss }:
<Modal isOpen onDismiss={onDismiss} title="Add instance to group">
<Modal.Body>
<Modal.Section>
<p className="text-sm text-gray-500">
<p>
Select an instance to add to the anti-affinity group{' '}
<HL>{antiAffinityGroup}</HL>. Only stopped instances can be added to the group.
<HL>{antiAffinityGroup}</HL>.
</p>
<form id={formId} onSubmit={onSubmit}>
<ComboboxField
Expand Down
1 change: 1 addition & 0 deletions app/pages/system/silos/SiloPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export default function SiloPage() {
<Tab to={pb.siloIpPools(siloSelector)}>IP Pools</Tab>
<Tab to={pb.siloQuotas(siloSelector)}>Quotas</Tab>
<Tab to={pb.siloFleetRoles(siloSelector)}>Fleet roles</Tab>
<Tab to={pb.siloScim(siloSelector)}>SCIM</Tab>
</RouteTabs>
</>
)
Expand Down
273 changes: 273 additions & 0 deletions app/pages/system/silos/SiloScimTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
/*
* 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<ScimClientBearerToken>()

const EmptyState = () => (
<TableEmptyBox border={false}>
<EmptyMessage
icon={<AccessToken24Icon />}
title="No SCIM tokens"
body="Create a token to see it here"
/>
</TableEmptyBox>
)

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) => (
<Truncate text={info.getValue()} position="middle" maxLength={18} />
),
}),
colHelper.accessor('timeCreated', Columns.timeCreated),
colHelper.accessor('timeExpires', {
header: 'Expires',
cell: (info) => {
const expires = info.getValue()
return expires ? (
<DateTime date={expires} />
) : (
<Badge color="neutral">Never</Badge>
)
},
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 (
<>
<CardBlock>
<CardBlock.Header
title="SCIM Bearer Tokens"
titleId="scim-tokens-label"
description="Manage authentication tokens for SCIM identity provisioning"
>
<CreateButton onClick={() => setShowCreateModal(true)}>Create token</CreateButton>
</CardBlock.Header>
<CardBlock.Body>
{tokens.length === 0 ? (
<EmptyState />
) : (
<Table
aria-labelledby="scim-tokens-label"
table={table}
className="table-inline"
/>
)}
</CardBlock.Body>
{/* <CardBlock.Footer>
<div className="text-sans-md text-raise group-hover:bg-tertiary relative -ml-2 inline-block rounded py-1 pr-7 pl-2">
<span className="inline-block max-w-[300px] truncate align-middle">
Learn more about{' '}
<a
href={href}
className="text-accent group-hover:text-accent"
target="_blank"
rel="noopener noreferrer"
>
{linkText}
<OpenLink12Icon className="absolute top-1.5 ml-1 translate-y-[1px]" />
</a>
</span>
</div>
</CardBlock.Footer> */}
</CardBlock>

{showCreateModal && (
<CreateTokenModal
siloSelector={siloSelector}
onDismiss={() => setShowCreateModal(false)}
onSuccess={(token) => {
setShowCreateModal(false)
setCreatedToken(token)
}}
/>
)}

{createdToken && (
<TokenCreatedModal token={createdToken} onDismiss={() => 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 (
<Modal isOpen onDismiss={onDismiss} title="Create token">
<Modal.Section>
This token will have access to provision users and groups via SCIM. Store it
securely and never share it publicly.
</Modal.Section>

<Modal.Footer
onDismiss={onDismiss}
onAction={() => {
createToken.mutate({ query: { silo: siloSelector.silo } })
}}
actionText="Create"
actionLoading={createToken.isPending}
/>
</Modal>
)
}

function TokenCreatedModal({
token,
onDismiss,
}: {
token: {
id: string
bearerToken: string
timeCreated: Date
timeExpires?: Date | null
}
onDismiss: () => void
}) {
return (
<Modal isOpen onDismiss={onDismiss} title="Create token">
<Modal.Section>
<Message
variant="notice"
content="This is the only time you'll see this token. Copy it now and store it securely."
/>

<div className="mt-4">
<div className="text-sans-md text-raise mb-2">Bearer Token</div>
<div className="text-sans-md text-raise bg-default border-default flex items-stretch rounded border">
<div className="flex-1 overflow-hidden px-3 py-2.75 text-ellipsis">
{token.bearerToken}
</div>
<div className="border-default flex w-8 items-center justify-center border-l">
<CopyToClipboard text={token.bearerToken} />
</div>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think we need design polish from @benjaminleonard. I'd put the yellow bit at the top, for one. Maybe put the expiration at the bottom and display it in one line. Could do without the pseudo form field styling.

Image

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

heh just kidding. it's a lot closer to the design than I expected. The pseudo-input it too tall though.

image

</div>
</div>
</Modal.Section>

<Modal.Footer
onDismiss={onDismiss}
actionText="Done"
onAction={onDismiss}
showCancel={false}
/>
</Modal>
)
}
4 changes: 4 additions & 0 deletions app/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ export const routes = createRoutesFromElements(
path="fleet-roles"
lazy={() => import('./pages/system/silos/SiloFleetRolesTab').then(convert)}
/>
<Route
path="scim"
lazy={() => import('./pages/system/silos/SiloScimTab').then(convert)}
/>
</Route>
</Route>
<Route path="issues" element={null} />
Expand Down
32 changes: 18 additions & 14 deletions app/ui/lib/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,17 @@ const defaultIcon: Record<Variant, ReactElement> = {
}

const color: Record<Variant, string> = {
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<Variant, string> = {
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<Variant, string> = {
Expand Down Expand Up @@ -77,22 +77,26 @@ export const Message = ({
return (
<div
className={cn(
'elevation-1 relative flex items-start gap-2.5 overflow-hidden rounded-lg p-4',
'elevation-1 relative flex items-start gap-2 overflow-hidden rounded-lg border p-3 pr-5',
color[variant],
textColor[variant],
className
)}
>
{showIcon && (
<div className="mt-[2px] flex [&>svg]:h-3 [&>svg]:w-3">{defaultIcon[variant]}</div>
<div
className={cn(
'mt-[2px] flex [&>svg]:h-3 [&>svg]:w-3',
`[&>svg]:${textColor[variant]}`
)}
>
{defaultIcon[variant]}
</div>
)}
<div className="flex-1">
{title && <div className="text-sans-semi-md">{title}</div>}
<div
className={cn(
'text-sans-md [&>a]:underline',
title ? secondaryTextColor[variant] : textColor[variant]
)}
className={cn('text-sans-md [&>a]:tint-underline', secondaryTextColor[variant])}
>
{content}
</div>
Expand Down
10 changes: 7 additions & 3 deletions app/ui/lib/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ type FooterProps = {
actionLoading?: boolean
cancelText?: string
disabled?: boolean
showCancel?: boolean
} & MergeExclusive<{ formId: string }, { onAction: () => void }>

Modal.Footer = ({
Expand All @@ -125,13 +126,16 @@ Modal.Footer = ({
cancelText,
disabled,
formId,
showCancel = true,
}: FooterProps) => (
<footer className="border-secondary flex items-center justify-between border-t px-4 py-3">
<div className="mr-4">{children}</div>
<div className="space-x-2">
<Button variant="secondary" size="sm" onClick={onDismiss}>
{cancelText || 'Cancel'}
</Button>
{showCancel && (
<Button variant="secondary" size="sm" onClick={onDismiss}>
{cancelText || 'Cancel'}
</Button>
)}
<Button
type={formId ? 'submit' : 'button'}
form={formId}
Expand Down
Loading
Loading